Merge branch 'feature/new-py-api' into feature/docs

master
Alinson S. Xavier 4 years ago
commit ce78d5114a
No known key found for this signature in database
GPG Key ID: DCA0DAD4D2F58624

@ -27,7 +27,7 @@ docs:
install-deps: install-deps:
$(PIP) install --upgrade pip $(PIP) install --upgrade pip
$(PIP) install --upgrade -i https://pypi.gurobi.com gurobipy $(PIP) install --upgrade -i https://pypi.gurobi.com 'gurobipy>=9.5,<9.6'
$(PIP) install --upgrade xpress $(PIP) install --upgrade xpress
$(PIP) install --upgrade -r requirements.txt $(PIP) install --upgrade -r requirements.txt

@ -16,4 +16,4 @@ html_theme_options = {
"extra_navbar": "", "extra_navbar": "",
} }
html_title = f"MIPLearn {release}" html_title = f"MIPLearn {release}"
nbsphinx_execute = 'never' nbsphinx_execute = "never"

@ -19,6 +19,8 @@ from .instance.picklegz import (
write_pickle_gz, write_pickle_gz,
read_pickle_gz, read_pickle_gz,
write_pickle_gz_multiple, write_pickle_gz_multiple,
save,
load,
) )
from .log import setup_logger from .log import setup_logger
from .solvers.gurobi import GurobiSolver from .solvers.gurobi import GurobiSolver

@ -4,7 +4,7 @@
import logging import logging
import os import os
from typing import Dict, List from typing import Dict, List, Any, Optional
import pandas as pd import pandas as pd
@ -136,7 +136,7 @@ def run_benchmarks(
n_jobs: int = 4, n_jobs: int = 4,
n_trials: int = 1, n_trials: int = 1,
progress: bool = False, progress: bool = False,
solver=None, solver: Any = None,
) -> None: ) -> None:
if solver is None: if solver is None:
solver = GurobiPyomoSolver() solver = GurobiPyomoSolver()
@ -175,7 +175,7 @@ def run_benchmarks(
def plot( def plot(
results: pd.DataFrame, results: pd.DataFrame,
output: str = None, output: Optional[str] = None,
) -> None: ) -> None:
import matplotlib.pyplot as plt import matplotlib.pyplot as plt
import pandas as pd import pandas as pd

@ -1,7 +1,7 @@
# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization # MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization
# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. # Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved.
# Released under the modified BSD license. See COPYING.md for more details. # Released under the modified BSD license. See COPYING.md for more details.
import json
import logging import logging
from typing import Dict, List, Tuple, Optional, Any, Set from typing import Dict, List, Tuple, Optional, Any, Set
@ -36,7 +36,7 @@ class DynamicConstraintsComponent(Component):
self.classifier_prototype: Classifier = classifier self.classifier_prototype: Classifier = classifier
self.classifiers: Dict[ConstraintCategory, Classifier] = {} self.classifiers: Dict[ConstraintCategory, Classifier] = {}
self.thresholds: Dict[ConstraintCategory, Threshold] = {} self.thresholds: Dict[ConstraintCategory, Threshold] = {}
self.known_cids: List[ConstraintName] = [] self.known_violations: Dict[ConstraintName, Any] = {}
self.attr = attr self.attr = attr
def sample_xy_with_cids( def sample_xy_with_cids(
@ -48,18 +48,19 @@ class DynamicConstraintsComponent(Component):
Dict[ConstraintCategory, List[List[bool]]], Dict[ConstraintCategory, List[List[bool]]],
Dict[ConstraintCategory, List[ConstraintName]], Dict[ConstraintCategory, List[ConstraintName]],
]: ]:
if len(self.known_cids) == 0: if len(self.known_violations) == 0:
return {}, {}, {} return {}, {}, {}
assert instance is not None assert instance is not None
x: Dict[ConstraintCategory, List[List[float]]] = {} x: Dict[ConstraintCategory, List[List[float]]] = {}
y: Dict[ConstraintCategory, List[List[bool]]] = {} y: Dict[ConstraintCategory, List[List[bool]]] = {}
cids: Dict[ConstraintCategory, List[ConstraintName]] = {} 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 = None
enforced_cids_np = sample.get_array(self.attr) enforced_encoded = sample.get_scalar(self.attr)
if enforced_cids_np is not None: if enforced_encoded is not None:
enforced_cids = list(enforced_cids_np) enforced = self.decode(enforced_encoded)
enforced_cids = list(enforced.keys())
# Get user-provided constraint features # Get user-provided constraint features
( (
@ -100,13 +101,10 @@ class DynamicConstraintsComponent(Component):
@overrides @overrides
def pre_fit(self, pre: List[Any]) -> None: def pre_fit(self, pre: List[Any]) -> None:
assert pre is not None assert pre is not None
known_cids: Set = set() self.known_violations.clear()
for cids in pre: for violations in pre:
if cids is None: for (vname, vdata) in violations.items():
continue self.known_violations[vname] = vdata
known_cids |= set(list(cids))
self.known_cids.clear()
self.known_cids.extend(sorted(known_cids))
def sample_predict( def sample_predict(
self, self,
@ -114,7 +112,7 @@ class DynamicConstraintsComponent(Component):
sample: Sample, sample: Sample,
) -> List[ConstraintName]: ) -> List[ConstraintName]:
pred: List[ConstraintName] = [] pred: List[ConstraintName] = []
if len(self.known_cids) == 0: if len(self.known_violations) == 0:
logger.info("Classifiers not fitted. Skipping.") logger.info("Classifiers not fitted. Skipping.")
return pred return pred
x, _, cids = self.sample_xy_with_cids(instance, sample) x, _, cids = self.sample_xy_with_cids(instance, sample)
@ -133,7 +131,9 @@ class DynamicConstraintsComponent(Component):
@overrides @overrides
def pre_sample_xy(self, instance: Instance, sample: Sample) -> Any: 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 @overrides
def fit_xy( def fit_xy(
@ -155,11 +155,13 @@ class DynamicConstraintsComponent(Component):
instance: Instance, instance: Instance,
sample: Sample, sample: Sample,
) -> Dict[str, float]: ) -> Dict[str, float]:
actual = sample.get_array(self.attr) attr_encoded = sample.get_scalar(self.attr)
assert actual is not None assert attr_encoded is not None
actual_violations = DynamicConstraintsComponent.decode(attr_encoded)
actual = set(actual_violations.keys())
pred = set(self.sample_predict(instance, sample)) pred = set(self.sample_predict(instance, sample))
tp, tn, fp, fn = 0, 0, 0, 0 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 pred:
if cid in actual: if cid in actual:
tp += 1 tp += 1
@ -171,3 +173,12 @@ class DynamicConstraintsComponent(Component):
else: else:
tn += 1 tn += 1
return classifier_evaluation_dict(tp=tp, tn=tn, fp=fp, fn=fn) 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()}

@ -1,10 +1,8 @@
# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization # MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization
# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. # Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved.
# Released under the modified BSD license. See COPYING.md for more details. # Released under the modified BSD license. See COPYING.md for more details.
import logging import logging
import pdb from typing import Dict, List, TYPE_CHECKING, Tuple, Any, Optional
from typing import Dict, List, TYPE_CHECKING, Tuple, Any, Optional, Set
import numpy as np import numpy as np
from overrides import overrides from overrides import overrides
@ -37,23 +35,23 @@ class DynamicLazyConstraintsComponent(Component):
self.dynamic: DynamicConstraintsComponent = DynamicConstraintsComponent( self.dynamic: DynamicConstraintsComponent = DynamicConstraintsComponent(
classifier=classifier, classifier=classifier,
threshold=threshold, threshold=threshold,
attr="mip_constr_lazy_enforced", attr="mip_constr_lazy",
) )
self.classifiers = self.dynamic.classifiers self.classifiers = self.dynamic.classifiers
self.thresholds = self.dynamic.thresholds self.thresholds = self.dynamic.thresholds
self.known_cids = self.dynamic.known_cids self.known_violations = self.dynamic.known_violations
self.lazy_enforced: Set[ConstraintName] = set() self.lazy_enforced: Dict[ConstraintName, Any] = {}
@staticmethod @staticmethod
def enforce( def enforce(
cids: List[ConstraintName], violations: Dict[ConstraintName, Any],
instance: Instance, instance: Instance,
model: Any, model: Any,
solver: "LearningSolver", solver: "LearningSolver",
) -> None: ) -> None:
assert solver.internal_solver is not None assert solver.internal_solver is not None
for cid in cids: for (vname, vdata) in violations.items():
instance.enforce_lazy_constraint(solver.internal_solver, model, cid) instance.enforce_lazy_constraint(solver.internal_solver, model, vdata)
@overrides @overrides
def before_solve_mip( def before_solve_mip(
@ -66,9 +64,10 @@ class DynamicLazyConstraintsComponent(Component):
) -> None: ) -> None:
self.lazy_enforced.clear() self.lazy_enforced.clear()
logger.info("Predicting violated (dynamic) lazy constraints...") logger.info("Predicting violated (dynamic) lazy constraints...")
cids = self.dynamic.sample_predict(instance, sample) vnames = self.dynamic.sample_predict(instance, sample)
logger.info("Enforcing %d lazy constraints..." % len(cids)) violations = {c: self.dynamic.known_violations[c] for c in vnames}
self.enforce(cids, instance, model, solver) logger.info("Enforcing %d lazy constraints..." % len(vnames))
self.enforce(violations, instance, model, solver)
@overrides @overrides
def after_solve_mip( def after_solve_mip(
@ -79,10 +78,7 @@ class DynamicLazyConstraintsComponent(Component):
stats: LearningSolveStats, stats: LearningSolveStats,
sample: Sample, sample: Sample,
) -> None: ) -> None:
sample.put_array( sample.put_scalar("mip_constr_lazy", self.dynamic.encode(self.lazy_enforced))
"mip_constr_lazy_enforced",
np.array(list(self.lazy_enforced), dtype="S"),
)
@overrides @overrides
def iteration_cb( def iteration_cb(
@ -93,14 +89,17 @@ class DynamicLazyConstraintsComponent(Component):
) -> bool: ) -> bool:
assert solver.internal_solver is not None assert solver.internal_solver is not None
logger.debug("Finding violated lazy constraints...") logger.debug("Finding violated lazy constraints...")
cids = instance.find_violated_lazy_constraints(solver.internal_solver, model) violations = instance.find_violated_lazy_constraints(
if len(cids) == 0: solver.internal_solver, model
)
if len(violations) == 0:
logger.debug("No violations found") logger.debug("No violations found")
return False return False
else: else:
self.lazy_enforced |= set(cids) for v in violations:
logger.debug(" %d violations found" % len(cids)) self.lazy_enforced[v] = violations[v]
self.enforce(cids, instance, model, solver) logger.debug(" %d violations found" % len(violations))
self.enforce(violations, instance, model, solver)
return True return True
# Delegate ML methods to self.dynamic # Delegate ML methods to self.dynamic

@ -1,9 +1,8 @@
# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization # MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization
# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. # Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved.
# Released under the modified BSD license. See COPYING.md for more details. # Released under the modified BSD license. See COPYING.md for more details.
import logging 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 import numpy as np
from overrides import overrides from overrides import overrides
@ -32,9 +31,9 @@ class UserCutsComponent(Component):
self.dynamic = DynamicConstraintsComponent( self.dynamic = DynamicConstraintsComponent(
classifier=classifier, classifier=classifier,
threshold=threshold, 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 self.n_added_in_callback = 0
@overrides @overrides
@ -50,11 +49,12 @@ class UserCutsComponent(Component):
self.enforced.clear() self.enforced.clear()
self.n_added_in_callback = 0 self.n_added_in_callback = 0
logger.info("Predicting violated user cuts...") logger.info("Predicting violated user cuts...")
cids = self.dynamic.sample_predict(instance, sample) vnames = self.dynamic.sample_predict(instance, sample)
logger.info("Enforcing %d user cuts ahead-of-time..." % len(cids)) logger.info("Enforcing %d user cuts ahead-of-time..." % len(vnames))
for cid in cids: for vname in vnames:
instance.enforce_user_cut(solver.internal_solver, model, cid) vdata = self.dynamic.known_violations[vname]
stats["UserCuts: Added ahead-of-time"] = len(cids) instance.enforce_user_cut(solver.internal_solver, model, vdata)
stats["UserCuts: Added ahead-of-time"] = len(vnames)
@overrides @overrides
def user_cut_cb( def user_cut_cb(
@ -65,18 +65,17 @@ class UserCutsComponent(Component):
) -> None: ) -> None:
assert solver.internal_solver is not None assert solver.internal_solver is not None
logger.debug("Finding violated user cuts...") logger.debug("Finding violated user cuts...")
cids = instance.find_violated_user_cuts(model) violations = instance.find_violated_user_cuts(model)
logger.debug(f"Found {len(cids)} violated user cuts") logger.debug(f"Found {len(violations)} violated user cuts")
logger.debug("Building violated user cuts...") logger.debug("Building violated user cuts...")
for cid in cids: for (vname, vdata) in violations.items():
if cid in self.enforced: if vname in self.enforced:
continue continue
assert isinstance(cid, ConstraintName) instance.enforce_user_cut(solver.internal_solver, model, vdata)
instance.enforce_user_cut(solver.internal_solver, model, cid) self.enforced[vname] = vdata
self.enforced.add(cid)
self.n_added_in_callback += 1 self.n_added_in_callback += 1
if len(cids) > 0: if len(violations) > 0:
logger.debug(f"Added {len(cids)} violated user cuts") logger.debug(f"Added {len(violations)} violated user cuts")
@overrides @overrides
def after_solve_mip( def after_solve_mip(
@ -87,10 +86,7 @@ class UserCutsComponent(Component):
stats: LearningSolveStats, stats: LearningSolveStats,
sample: Sample, sample: Sample,
) -> None: ) -> None:
sample.put_array( sample.put_scalar("mip_user_cuts", self.dynamic.encode(self.enforced))
"mip_user_cuts_enforced",
np.array(list(self.enforced), dtype="S"),
)
stats["UserCuts: Added in callback"] = self.n_added_in_callback stats["UserCuts: Added in callback"] = self.n_added_in_callback
if self.n_added_in_callback > 0: if self.n_added_in_callback > 0:
logger.info(f"{self.n_added_in_callback} user cuts added in callback") logger.info(f"{self.n_added_in_callback} user cuts added in callback")
@ -133,5 +129,5 @@ class UserCutsComponent(Component):
self, self,
instance: "Instance", instance: "Instance",
sample: Sample, sample: Sample,
) -> Dict[ConstraintCategory, Dict[str, float]]: ) -> Dict[ConstraintCategory, Dict[ConstraintName, float]]:
return self.dynamic.sample_evaluate(instance, sample) return self.dynamic.sample_evaluate(instance, sample)

@ -9,7 +9,7 @@ from typing import Any, List, TYPE_CHECKING, Dict
import numpy as np import numpy as np
from miplearn.features.sample import Sample, MemorySample from miplearn.features.sample import Sample, MemorySample
from miplearn.types import ConstraintName, ConstraintCategory from miplearn.types import ConstraintName
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -114,7 +114,7 @@ class Instance(ABC):
self, self,
solver: "InternalSolver", solver: "InternalSolver",
model: Any, model: Any,
) -> List[ConstraintName]: ) -> Dict[ConstraintName, Any]:
""" """
Returns lazy constraint violations found for the current solution. 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 resolve the problem. The process repeats until no further lazy constraint
violations are found. violations are found.
Each "violation" is simply a string which allows the instance to identify Violations should be returned in a dictionary mapping the name of the violation
unambiguously which lazy constraint should be generated. In the Traveling to some user-specified data that allows the instance to unambiguously generate
Salesman Problem, for example, a subtour violation could be a string the lazy constraints at a later time. In the Traveling Salesman Problem, for
containing the cities in the subtour. 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 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. is configured to use lazy callbacks, this solution may be non-integer.
For a concrete example, see TravelingSalesmanInstance. For a concrete example, see TravelingSalesmanInstance.
""" """
return [] return {}
def enforce_lazy_constraint( def enforce_lazy_constraint(
self, self,
solver: "InternalSolver", solver: "InternalSolver",
model: Any, model: Any,
violation: ConstraintName, violation_data: Any,
) -> None: ) -> None:
""" """
Adds constraints to the model to ensure that the given violation is fixed. Adds constraints to the model to ensure that the given violation is fixed.
This method is typically called immediately after This method is typically called immediately after
find_violated_lazy_constraints. The violation object provided to this method `find_violated_lazy_constraints`. The argument `violation_data` is the
is exactly the same object returned earlier by user-provided data, previously returned by `find_violated_lazy_constraints`.
find_violated_lazy_constraints. After some training, LearningSolver may In the Traveling Salesman Problem, for example, it could be a list of cities
decide to proactively build some lazy constraints at the beginning of the in the subtour.
optimization process, before a solution is even available. In this case,
enforce_lazy_constraints will be called without a corresponding call to After some training, LearningSolver may decide to proactively build some lazy
find_violated_lazy_constraints. constraints at the beginning of the optimization process, before a solution
is even available. In this case, `enforce_lazy_constraints` will be called
Note that this method can be called either before the optimization starts or without a corresponding call to `find_violated_lazy_constraints`.
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.
For a concrete example, see TravelingSalesmanInstance. For a concrete example, see TravelingSalesmanInstance.
""" """
@ -166,14 +172,14 @@ class Instance(ABC):
def has_user_cuts(self) -> bool: def has_user_cuts(self) -> bool:
return False return False
def find_violated_user_cuts(self, model: Any) -> List[ConstraintName]: def find_violated_user_cuts(self, model: Any) -> Dict[ConstraintName, Any]:
return [] return {}
def enforce_user_cut( def enforce_user_cut(
self, self,
solver: "InternalSolver", solver: "InternalSolver",
model: Any, model: Any,
violation: ConstraintName, violation_data: Any,
) -> Any: ) -> Any:
return None return None

@ -3,15 +3,15 @@
# Released under the modified BSD license. See COPYING.md for more details. # Released under the modified BSD license. See COPYING.md for more details.
import gc import gc
import os import os
from typing import Any, Optional, List, Dict, TYPE_CHECKING
import pickle import pickle
from typing import Any, Optional, List, Dict, TYPE_CHECKING
import numpy as np import numpy as np
from overrides import overrides from overrides import overrides
from miplearn.features.sample import Hdf5Sample, Sample from miplearn.features.sample import Hdf5Sample, Sample
from miplearn.instance.base import Instance from miplearn.instance.base import Instance
from miplearn.types import ConstraintName, ConstraintCategory from miplearn.types import ConstraintName
if TYPE_CHECKING: if TYPE_CHECKING:
from miplearn.solvers.learning import InternalSolver from miplearn.solvers.learning import InternalSolver
@ -71,7 +71,7 @@ class FileInstance(Instance):
self, self,
solver: "InternalSolver", solver: "InternalSolver",
model: Any, model: Any,
) -> List[ConstraintName]: ) -> Dict[ConstraintName, Any]:
assert self.instance is not None assert self.instance is not None
return self.instance.find_violated_lazy_constraints(solver, model) return self.instance.find_violated_lazy_constraints(solver, model)
@ -80,13 +80,13 @@ class FileInstance(Instance):
self, self,
solver: "InternalSolver", solver: "InternalSolver",
model: Any, model: Any,
violation: ConstraintName, violation_data: Any,
) -> None: ) -> None:
assert self.instance is not 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 @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 assert self.instance is not None
return self.instance.find_violated_user_cuts(model) return self.instance.find_violated_user_cuts(model)
@ -95,10 +95,10 @@ class FileInstance(Instance):
self, self,
solver: "InternalSolver", solver: "InternalSolver",
model: Any, model: Any,
violation: ConstraintName, violation_data: Any,
) -> None: ) -> None:
assert self.instance is not 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 # Input & Output
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------

@ -6,14 +6,14 @@ import gc
import gzip import gzip
import os import os
import pickle 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 import numpy as np
from overrides import overrides from overrides import overrides
from miplearn.features.sample import Sample from miplearn.features.sample import Sample
from miplearn.instance.base import Instance from miplearn.instance.base import Instance
from miplearn.types import ConstraintName, ConstraintCategory from miplearn.types import ConstraintName
if TYPE_CHECKING: if TYPE_CHECKING:
from miplearn.solvers.learning import InternalSolver from miplearn.solvers.learning import InternalSolver
@ -83,7 +83,7 @@ class PickleGzInstance(Instance):
self, self,
solver: "InternalSolver", solver: "InternalSolver",
model: Any, model: Any,
) -> List[ConstraintName]: ) -> Dict[ConstraintName, Any]:
assert self.instance is not None assert self.instance is not None
return self.instance.find_violated_lazy_constraints(solver, model) return self.instance.find_violated_lazy_constraints(solver, model)
@ -92,13 +92,13 @@ class PickleGzInstance(Instance):
self, self,
solver: "InternalSolver", solver: "InternalSolver",
model: Any, model: Any,
violation: ConstraintName, violation_data: Any,
) -> None: ) -> None:
assert self.instance is not 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 @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 assert self.instance is not None
return self.instance.find_violated_user_cuts(model) return self.instance.find_violated_user_cuts(model)
@ -107,10 +107,10 @@ class PickleGzInstance(Instance):
self, self,
solver: "InternalSolver", solver: "InternalSolver",
model: Any, model: Any,
violation: ConstraintName, violation_name: Any,
) -> None: ) -> None:
assert self.instance is not 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 @overrides
def load(self) -> None: def load(self) -> None:
@ -153,3 +153,33 @@ def read_pickle_gz(filename: str) -> Any:
def write_pickle_gz_multiple(objs: List[Any], dirname: str) -> None: def write_pickle_gz_multiple(objs: List[Any], dirname: str) -> None:
for (i, obj) in enumerate(objs): for (i, obj) in enumerate(objs):
write_pickle_gz(obj, f"{dirname}/{i:05d}.pkl.gz") 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
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)

@ -2,6 +2,7 @@
# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. # Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved.
# Released under the modified BSD license. See COPYING.md for more details. # Released under the modified BSD license. See COPYING.md for more details.
from dataclasses import dataclass
from typing import List, Dict, Optional from typing import List, Dict, Optional
import numpy as np import numpy as np
@ -13,6 +14,13 @@ from scipy.stats.distributions import rv_frozen
from miplearn.instance.base import Instance from miplearn.instance.base import Instance
@dataclass
class MultiKnapsackData:
prices: np.ndarray
capacities: np.ndarray
weights: np.ndarray
class MultiKnapsackInstance(Instance): class MultiKnapsackInstance(Instance):
"""Representation of the Multidimensional 0-1 Knapsack Problem. """Representation of the Multidimensional 0-1 Knapsack Problem.

@ -2,7 +2,8 @@
# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. # Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved.
# Released under the modified BSD license. See COPYING.md for more details. # 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 networkx as nx
import numpy as np import numpy as np
@ -15,6 +16,12 @@ from scipy.stats.distributions import rv_frozen
from miplearn.instance.base import Instance from miplearn.instance.base import Instance
@dataclass
class MaxWeightStableSetData:
graph: Graph
weights: np.ndarray
class MaxWeightStableSetInstance(Instance): class MaxWeightStableSetInstance(Instance):
"""An instance of the Maximum-Weight Stable Set Problem. """An instance of the Maximum-Weight Stable Set Problem.
@ -87,16 +94,30 @@ class MaxWeightStableSetGenerator:
if fix_graph: if fix_graph:
self.graph = self._generate_graph() self.graph = self._generate_graph()
def generate(self, n_samples: int) -> List[MaxWeightStableSetInstance]: def generate(self, n_samples: int) -> List[MaxWeightStableSetData]:
def _sample() -> MaxWeightStableSetInstance: def _sample() -> MaxWeightStableSetData:
if self.graph is not None: if self.graph is not None:
graph = self.graph graph = self.graph
else: else:
graph = self._generate_graph() graph = self._generate_graph()
weights = self.w.rvs(graph.number_of_nodes()) weights = self.w.rvs(graph.number_of_nodes())
return MaxWeightStableSetInstance(graph, weights) return MaxWeightStableSetData(graph, weights)
return [_sample() for _ in range(n_samples)] return [_sample() for _ in range(n_samples)]
def _generate_graph(self) -> Graph: def _generate_graph(self) -> Graph:
return nx.generators.random_graphs.binomial_graph(self.n.rvs(), self.p.rvs()) 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

@ -1,7 +1,9 @@
# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization # MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization
# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. # Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved.
# Released under the modified BSD license. See COPYING.md for more details. # Released under the modified BSD license. See COPYING.md for more details.
from typing import List, Tuple, FrozenSet, Any, Optional, Dict
from dataclasses import dataclass
from typing import List, Tuple, Any, Optional, Dict
import networkx as nx import networkx as nx
import numpy as np import numpy as np
@ -17,6 +19,12 @@ from miplearn.solvers.pyomo.base import BasePyomoSolver
from miplearn.types import ConstraintName from miplearn.types import ConstraintName
@dataclass
class TravelingSalesmanData:
n_cities: int
distances: np.ndarray
class TravelingSalesmanInstance(Instance): class TravelingSalesmanInstance(Instance):
"""An instance ot the Traveling Salesman Problem. """An instance ot the Traveling Salesman Problem.
@ -62,14 +70,15 @@ class TravelingSalesmanInstance(Instance):
self, self,
solver: InternalSolver, solver: InternalSolver,
model: Any, model: Any,
) -> List[ConstraintName]: ) -> Dict[ConstraintName, List]:
selected_edges = [e for e in self.edges if model.x[e].value > 0.5] selected_edges = [e for e in self.edges if model.x[e].value > 0.5]
graph = nx.Graph() graph = nx.Graph()
graph.add_edges_from(selected_edges) graph.add_edges_from(selected_edges)
violations = [] violations = {}
for c in list(nx.connected_components(graph)): for c in list(nx.connected_components(graph)):
if len(c) < self.n_cities: 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 return violations
@overrides @overrides
@ -77,10 +86,9 @@ class TravelingSalesmanInstance(Instance):
self, self,
solver: InternalSolver, solver: InternalSolver,
model: Any, model: Any,
violation: ConstraintName, component: List,
) -> None: ) -> None:
assert isinstance(solver, BasePyomoSolver) assert isinstance(solver, BasePyomoSolver)
component = [int(v) for v in violation.decode().split(",")]
cut_edges = [ cut_edges = [
e e
for e in self.edges for e in self.edges
@ -156,8 +164,8 @@ class TravelingSalesmanGenerator:
self.fixed_n = None self.fixed_n = None
self.fixed_cities = None self.fixed_cities = None
def generate(self, n_samples: int) -> List[TravelingSalesmanInstance]: def generate(self, n_samples: int) -> List[TravelingSalesmanData]:
def _sample() -> TravelingSalesmanInstance: def _sample() -> TravelingSalesmanData:
if self.fixed_cities is not None: if self.fixed_cities is not None:
assert self.fixed_n is not None assert self.fixed_n is not None
n, cities = self.fixed_n, self.fixed_cities n, cities = self.fixed_n, self.fixed_cities
@ -167,7 +175,7 @@ class TravelingSalesmanGenerator:
distances = np.tril(distances) + np.triu(distances.T, 1) distances = np.tril(distances) + np.triu(distances.T, 1)
if self.round: if self.round:
distances = distances.round() distances = distances.round()
return TravelingSalesmanInstance(n, distances) return TravelingSalesmanData(n, distances)
return [_sample() for _ in range(n_samples)] return [_sample() for _ in range(n_samples)]

@ -710,7 +710,7 @@ class GurobiTestInstanceKnapsack(PyomoTestInstanceKnapsack):
self, self,
solver: InternalSolver, solver: InternalSolver,
model: Any, model: Any,
violation: str, violation_data: Any,
) -> None: ) -> None:
x0 = model.getVarByName("x[0]") x0 = model.getVarByName("x[0]")
model.cbLazy(x0 <= 0) model.cbLazy(x0 <= 0)

@ -5,11 +5,13 @@
import logging import logging
import time import time
import traceback import traceback
from typing import Optional, List, Any, cast, Dict, Tuple from typing import Optional, List, Any, cast, Dict, Tuple, Callable, IO, Union
from overrides import overrides
from p_tqdm import p_map, p_umap from p_tqdm import p_map, p_umap
from tqdm.auto import tqdm from tqdm.auto import tqdm
from miplearn.features.sample import Hdf5Sample, Sample
from miplearn.components.component import Component from miplearn.components.component import Component
from miplearn.components.dynamic_lazy import DynamicLazyConstraintsComponent from miplearn.components.dynamic_lazy import DynamicLazyConstraintsComponent
from miplearn.components.dynamic_user_cuts import UserCutsComponent from miplearn.components.dynamic_user_cuts import UserCutsComponent
@ -17,15 +19,58 @@ from miplearn.components.objective import ObjectiveValueComponent
from miplearn.components.primal import PrimalSolutionComponent from miplearn.components.primal import PrimalSolutionComponent
from miplearn.features.extractor import FeaturesExtractor from miplearn.features.extractor import FeaturesExtractor
from miplearn.instance.base import Instance from miplearn.instance.base import Instance
from miplearn.instance.picklegz import PickleGzInstance
from miplearn.solvers import _RedirectOutput from miplearn.solvers import _RedirectOutput
from miplearn.solvers.internal import InternalSolver from miplearn.solvers.internal import InternalSolver
from miplearn.solvers.pyomo.gurobi import GurobiPyomoSolver from miplearn.solvers.pyomo.gurobi import GurobiPyomoSolver
from miplearn.types import LearningSolveStats from miplearn.types import LearningSolveStats
import gzip
import pickle
import miplearn
from os.path import exists
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class FileInstanceWrapper(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:
return miplearn.load(self.filename, self.build_model)
@overrides
def create_sample(self) -> Sample:
return self.sample
@overrides
def get_samples(self) -> List[Sample]:
return [self.sample]
class MemoryInstanceWrapper(Instance):
def __init__(self, model: Any) -> None:
super().__init__()
assert model is not None
self.model = model
@overrides
def to_model(self) -> Any:
return self.model
class _GlobalVariables: class _GlobalVariables:
def __init__(self) -> None: def __init__(self) -> None:
self.solver: Optional[LearningSolver] = None self.solver: Optional[LearningSolver] = None
@ -48,7 +93,7 @@ def _parallel_solve(
assert solver is not None assert solver is not None
assert instances is not None assert instances is not None
try: try:
stats = solver.solve( stats = solver._solve(
instances[idx], instances[idx],
discard_output=discard_outputs, discard_output=discard_outputs,
) )
@ -87,11 +132,6 @@ class LearningSolver:
option should be activated if the LP relaxation is not very option should be activated if the LP relaxation is not very
expensive to solve and if it provides good hints for the integer expensive to solve and if it provides good hints for the integer
solution. 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__( def __init__(
@ -101,7 +141,6 @@ class LearningSolver:
solver: Optional[InternalSolver] = None, solver: Optional[InternalSolver] = None,
use_lazy_cb: bool = False, use_lazy_cb: bool = False,
solve_lp: bool = True, solve_lp: bool = True,
simulate_perfect: bool = False,
extractor: Optional[FeaturesExtractor] = None, extractor: Optional[FeaturesExtractor] = None,
extract_lhs: bool = True, extract_lhs: bool = True,
extract_sa: bool = True, extract_sa: bool = True,
@ -118,7 +157,6 @@ class LearningSolver:
self.internal_solver: Optional[InternalSolver] = None self.internal_solver: Optional[InternalSolver] = None
self.internal_solver_prototype: InternalSolver = solver self.internal_solver_prototype: InternalSolver = solver
self.mode: str = mode self.mode: str = mode
self.simulate_perfect: bool = simulate_perfect
self.solve_lp: bool = solve_lp self.solve_lp: bool = solve_lp
self.tee = False self.tee = False
self.use_lazy_cb: bool = use_lazy_cb self.use_lazy_cb: bool = use_lazy_cb
@ -140,6 +178,44 @@ class LearningSolver:
discard_output: bool = False, discard_output: bool = False,
tee: bool = False, tee: bool = False,
) -> LearningSolveStats: ) -> 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 # Generate model
# ------------------------------------------------------- # -------------------------------------------------------
@ -300,65 +376,25 @@ class LearningSolver:
def solve( def solve(
self, self,
instance: Instance, arg: Union[Any, List[str]],
model: Any = None, build_model: Optional[Callable] = None,
discard_output: bool = False,
tee: bool = False, tee: bool = False,
) -> LearningSolveStats: ) -> Union[LearningSolveStats, List[LearningSolveStats]]:
""" if isinstance(arg, list):
Solves the given instance. If trained machine-learning models are assert build_model is not None
available, they will be used to accelerate the solution process. stats = []
for i in arg:
The argument `instance` may be either an Instance object or a s = self._solve(FileInstanceWrapper(i, build_model), tee=tee)
filename pointing to a pickled Instance object. stats.append(s)
return stats
This method adds a new training sample to `instance.training_sample`. else:
If a filename is provided, then the file is modified in-place. That is, return self._solve(MemoryInstanceWrapper(arg), tee=tee)
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, def fit(self, filenames: List[str], build_model: Callable) -> None:
ObjectiveValueComponent adds the keys "Predicted LB" and instances: List[Instance] = [
"Predicted UB". See the documentation of each component for more FileInstanceWrapper(f, build_model) for f in filenames
details. ]
""" self._fit(instances)
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 parallel_solve( def parallel_solve(
self, self,
@ -397,7 +433,7 @@ class LearningSolver:
""" """
if n_jobs == 1: if n_jobs == 1:
return [ return [
self.solve(p) self._solve(p)
for p in tqdm( for p in tqdm(
instances, instances,
disable=not progress, disable=not progress,
@ -425,7 +461,7 @@ class LearningSolver:
self._restore_miplearn_logger() self._restore_miplearn_logger()
return stats return stats
def fit( def _fit(
self, self,
training_instances: List[Instance], training_instances: List[Instance],
n_jobs: int = 1, n_jobs: int = 1,

@ -322,8 +322,14 @@ class BasePyomoSolver(InternalSolver):
# Bounds # Bounds
lb, ub = v.bounds lb, ub = v.bounds
if ub is not None:
upper_bounds.append(float(ub)) upper_bounds.append(float(ub))
else:
upper_bounds.append(float("inf"))
if lb is not None:
lower_bounds.append(float(lb)) lower_bounds.append(float(lb))
else:
lower_bounds.append(-float("inf"))
# Objective coefficient # Objective coefficient
if v.name in self._obj: if v.name in self._obj:
@ -391,7 +397,9 @@ class BasePyomoSolver(InternalSolver):
) -> None: ) -> None:
if model is None: if model is None:
model = instance.to_model() 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.instance = instance
self.model = model self.model = model
self.model.extra_constraints = ConstraintList() self.model.extra_constraints = ConstraintList()

@ -247,7 +247,7 @@ def run_lazy_cb_tests(solver: InternalSolver) -> None:
assert relsol is not None assert relsol is not None
assert relsol[b"x[0]"] is not None assert relsol[b"x[0]"] is not None
if relsol[b"x[0]"] > 0: 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.set_instance(instance, model)
solver.solve(lazy_cb=lazy_cb) solver.solve(lazy_cb=lazy_cb)

@ -10,6 +10,7 @@ import pytest
from miplearn.classifiers import Classifier from miplearn.classifiers import Classifier
from miplearn.classifiers.threshold import MinProbabilityThreshold from miplearn.classifiers.threshold import MinProbabilityThreshold
from miplearn.components import classifier_evaluation_dict from miplearn.components import classifier_evaluation_dict
from miplearn.components.dynamic_common import DynamicConstraintsComponent
from miplearn.components.dynamic_lazy import DynamicLazyConstraintsComponent from miplearn.components.dynamic_lazy import DynamicLazyConstraintsComponent
from miplearn.features.sample import MemorySample from miplearn.features.sample import MemorySample
from miplearn.instance.base import Instance from miplearn.instance.base import Instance
@ -24,13 +25,23 @@ def training_instances() -> List[Instance]:
samples_0 = [ samples_0 = [
MemorySample( 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]), "static_instance_features": np.array([5.0]),
}, },
), ),
MemorySample( 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]), "static_instance_features": np.array([5.0]),
}, },
), ),
@ -55,7 +66,12 @@ def training_instances() -> List[Instance]:
samples_1 = [ samples_1 = [
MemorySample( 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]), "static_instance_features": np.array([8.0]),
}, },
) )
@ -83,8 +99,8 @@ def test_sample_xy(training_instances: List[Instance]) -> None:
comp = DynamicLazyConstraintsComponent() comp = DynamicLazyConstraintsComponent()
comp.pre_fit( comp.pre_fit(
[ [
np.array(["c1", "c3", "c4"], dtype="S"), {b"c1": 0, b"c3": 0, b"c4": 0},
np.array(["c1", "c2", "c4"], dtype="S"), {b"c1": 0, b"c2": 0, b"c4": 0},
] ]
) )
x_expected = { 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: def test_sample_predict_evaluate(training_instances: List[Instance]) -> None:
comp = DynamicLazyConstraintsComponent() 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-a"] = MinProbabilityThreshold([0.5, 0.5])
comp.thresholds[b"type-b"] = MinProbabilityThreshold([0.5, 0.5]) comp.thresholds[b"type-b"] = MinProbabilityThreshold([0.5, 0.5])
comp.classifiers[b"type-a"] = Mock(spec=Classifier) comp.classifiers[b"type-a"] = Mock(spec=Classifier)

@ -1,10 +1,11 @@
# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization # MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization
# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. # Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved.
# Released under the modified BSD license. See COPYING.md for more details. # Released under the modified BSD license. See COPYING.md for more details.
import json
import logging import logging
from typing import Any, FrozenSet, List from typing import Any, List, Dict
import gurobipy
import gurobipy as gp import gurobipy as gp
import networkx as nx import networkx as nx
import pytest import pytest
@ -12,12 +13,11 @@ from gurobipy import GRB
from networkx import Graph from networkx import Graph
from overrides import overrides from overrides import overrides
from miplearn.solvers.learning import InternalSolver
from miplearn.components.dynamic_user_cuts import UserCutsComponent from miplearn.components.dynamic_user_cuts import UserCutsComponent
from miplearn.instance.base import Instance from miplearn.instance.base import Instance
from miplearn.solvers.gurobi import GurobiSolver from miplearn.solvers.gurobi import GurobiSolver
from miplearn.solvers.learning import LearningSolver from miplearn.solvers.learning import LearningSolver
from miplearn.types import ConstraintName, ConstraintCategory from miplearn.types import ConstraintName
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -41,25 +41,32 @@ class GurobiStableSetProblem(Instance):
return True return True
@overrides @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) assert isinstance(model, gp.Model)
try:
vals = model.cbGetNodeRel(model.getVars()) vals = model.cbGetNodeRel(model.getVars())
violations = [] except gurobipy.GurobiError:
return {}
violations = {}
for clique in nx.find_cliques(self.graph): for clique in nx.find_cliques(self.graph):
if sum(vals[i] for i in clique) > 1: 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 return violations
@overrides @overrides
def enforce_user_cut( def enforce_user_cut(
self, self,
solver: InternalSolver, solver: GurobiSolver,
model: Any, model: Any,
cid: ConstraintName, clique: List[int],
) -> Any: ) -> Any:
clique = [int(i) for i in cid.decode().split(",")]
x = model.getVars() 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 @pytest.fixture
@ -71,7 +78,7 @@ def stab_instance() -> Instance:
@pytest.fixture @pytest.fixture
def solver() -> LearningSolver: def solver() -> LearningSolver:
return LearningSolver( return LearningSolver(
solver=GurobiSolver(), solver=GurobiSolver(params={"Threads": 1}),
components=[UserCutsComponent()], components=[UserCutsComponent()],
) )
@ -80,16 +87,18 @@ def test_usage(
stab_instance: Instance, stab_instance: Instance,
solver: LearningSolver, solver: LearningSolver,
) -> None: ) -> None:
stats_before = solver.solve(stab_instance) stats_before = solver._solve(stab_instance)
sample = stab_instance.get_samples()[0] sample = stab_instance.get_samples()[0]
user_cuts_enforced = sample.get_array("mip_user_cuts_enforced") user_cuts_encoded = sample.get_scalar("mip_user_cuts")
assert user_cuts_enforced is not None assert user_cuts_encoded is not None
assert len(user_cuts_enforced) > 0 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 ahead-of-time"] == 0
assert stats_before["UserCuts: Added in callback"] > 0 assert stats_before["UserCuts: Added in callback"] > 0
solver.fit([stab_instance]) solver._fit([stab_instance])
stats_after = solver.solve(stab_instance) stats_after = solver._solve(stab_instance)
assert ( assert (
stats_after["UserCuts: Added ahead-of-time"] stats_after["UserCuts: Added ahead-of-time"]
== stats_before["UserCuts: Added in callback"] == stats_before["UserCuts: Added in callback"]

@ -134,8 +134,8 @@ def test_sample_evaluate(sample: Sample) -> None:
def test_usage() -> None: def test_usage() -> None:
solver = LearningSolver(components=[ObjectiveValueComponent()]) solver = LearningSolver(components=[ObjectiveValueComponent()])
instance = GurobiPyomoSolver().build_test_instance_knapsack() instance = GurobiPyomoSolver().build_test_instance_knapsack()
solver.solve(instance) solver._solve(instance)
solver.fit([instance]) solver._fit([instance])
stats = solver.solve(instance) stats = solver._solve(instance)
assert stats["mip_lower_bound"] == stats["Objective: Predicted lower bound"] assert stats["mip_lower_bound"] == stats["Objective: Predicted lower bound"]
assert stats["mip_upper_bound"] == stats["Objective: Predicted upper bound"] assert stats["mip_upper_bound"] == stats["Objective: Predicted upper bound"]

@ -13,7 +13,7 @@ from miplearn.classifiers.threshold import Threshold
from miplearn.components import classifier_evaluation_dict from miplearn.components import classifier_evaluation_dict
from miplearn.components.primal import PrimalSolutionComponent from miplearn.components.primal import PrimalSolutionComponent
from miplearn.features.sample import Sample, MemorySample 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.learning import LearningSolver
from miplearn.solvers.tests import assert_equals from miplearn.solvers.tests import assert_equals
@ -108,10 +108,11 @@ def test_usage() -> None:
] ]
) )
gen = TravelingSalesmanGenerator(n=randint(low=5, high=6)) gen = TravelingSalesmanGenerator(n=randint(low=5, high=6))
instance = gen.generate(1)[0] data = gen.generate(1)
solver.solve(instance) instance = TravelingSalesmanInstance(data[0].n_cities, data[0].distances)
solver.fit([instance]) solver._solve(instance)
stats = solver.solve(instance) solver._fit([instance])
stats = solver._solve(instance)
assert stats["Primal: Free"] == 0 assert stats["Primal: Free"] == 0
assert stats["Primal: One"] + stats["Primal: Zero"] == 10 assert stats["Primal: One"] + stats["Primal: Zero"] == 10
assert stats["mip_lower_bound"] == stats["mip_warm_start_value"] assert stats["mip_lower_bound"] == stats["mip_warm_start_value"]

@ -22,7 +22,7 @@ def test_usage() -> None:
# Solve instance from disk # Solve instance from disk
solver = LearningSolver(solver=GurobiSolver()) solver = LearningSolver(solver=GurobiSolver())
solver.solve(FileInstance(filename)) solver._solve(FileInstance(filename))
# Assert HDF5 contains training data # Assert HDF5 contains training data
sample = FileInstance(filename).get_samples()[0] sample = FileInstance(filename).get_samples()[0]

@ -1,10 +1,16 @@
# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization # MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization
# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. # Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved.
# Released under the modified BSD license. See COPYING.md for more details. # Released under the modified BSD license. See COPYING.md for more details.
import tempfile import tempfile
from typing import cast, IO
from miplearn.instance.picklegz import write_pickle_gz, PickleGzInstance from miplearn.instance.picklegz import write_pickle_gz, PickleGzInstance
from miplearn.solvers.gurobi import GurobiSolver from miplearn.solvers.gurobi import GurobiSolver
from miplearn import save
from os.path import exists
import gzip
import pickle
def test_usage() -> None: def test_usage() -> None:
@ -14,3 +20,14 @@ def test_usage() -> None:
pickled = PickleGzInstance(file.name) pickled = PickleGzInstance(file.name)
pickled.load() pickled.load()
assert pickled.to_model() is not None 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]

@ -6,7 +6,7 @@ import numpy as np
from scipy.stats import uniform, randint from scipy.stats import uniform, randint
from miplearn import LearningSolver from miplearn import LearningSolver
from miplearn.problems.knapsack import MultiKnapsackGenerator from miplearn.problems.knapsack import MultiKnapsackGenerator, MultiKnapsackInstance
def test_knapsack_generator() -> None: def test_knapsack_generator() -> None:
@ -18,17 +18,22 @@ def test_knapsack_generator() -> None:
u=uniform(loc=1.0, scale=1.0), u=uniform(loc=1.0, scale=1.0),
alpha=uniform(loc=0.50, scale=0.0), alpha=uniform(loc=0.50, scale=0.0),
) )
instances = gen.generate(100) data = gen.generate(100)
w_sum = sum(instance.weights for instance in instances) / len(instances) w_sum = sum(d.weights for d in data) / len(data)
b_sum = sum(instance.capacities for instance in instances) / len(instances) 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(w_sum)), -1) == 500.0
assert round(float(np.mean(b_sum)), -3) == 25000.0 assert round(float(np.mean(b_sum)), -3) == 25000.0
def test_knapsack() -> None: def test_knapsack() -> None:
instance = MultiKnapsackGenerator( data = MultiKnapsackGenerator(
n=randint(low=5, high=6), n=randint(low=5, high=6),
m=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 = LearningSolver()
solver.solve(instance) solver._solve(instance)

@ -15,7 +15,7 @@ def test_stab() -> None:
weights = np.array([1.0, 1.0, 1.0, 1.0, 1.0]) weights = np.array([1.0, 1.0, 1.0, 1.0, 1.0])
instance = MaxWeightStableSetInstance(graph, weights) instance = MaxWeightStableSetInstance(graph, weights)
solver = LearningSolver() solver = LearningSolver()
stats = solver.solve(instance) stats = solver._solve(instance)
assert stats["mip_lower_bound"] == 2.0 assert stats["mip_lower_bound"] == 2.0
@ -29,8 +29,8 @@ def test_stab_generator_fixed_graph() -> None:
p=uniform(loc=0.05, scale=0.0), p=uniform(loc=0.05, scale=0.0),
fix_graph=True, fix_graph=True,
) )
instances = gen.generate(1_000) data = gen.generate(1_000)
weights = np.array([instance.weights for instance in instances]) weights = np.array([d.weights for d in data])
weights_avg_actual = np.round(np.average(weights, axis=0)) weights_avg_actual = np.round(np.average(weights, axis=0))
weights_avg_expected = [55.0] * 10 weights_avg_expected = [55.0] * 10
assert list(weights_avg_actual) == weights_avg_expected 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), p=uniform(loc=0.5, scale=0.0),
fix_graph=False, fix_graph=False,
) )
instances = gen.generate(1_000) data = gen.generate(1_000)
n_nodes = [instance.graph.number_of_nodes() for instance in instances] n_nodes = [d.graph.number_of_nodes() for d in data]
n_edges = [instance.graph.number_of_edges() for instance in instances] 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_nodes)) == 35.0
assert np.round(np.mean(n_edges), -1) == 300.0 assert np.round(np.mean(n_edges), -1) == 300.0

@ -1,6 +1,7 @@
# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization # MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization
# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. # Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved.
# Released under the modified BSD license. See COPYING.md for more details. # Released under the modified BSD license. See COPYING.md for more details.
import json
import numpy as np import numpy as np
from numpy.linalg import norm from numpy.linalg import norm
@ -13,17 +14,17 @@ from miplearn.solvers.tests import assert_equals
def test_generator() -> None: def test_generator() -> None:
instances = TravelingSalesmanGenerator( data = TravelingSalesmanGenerator(
x=uniform(loc=0.0, scale=1000.0), x=uniform(loc=0.0, scale=1000.0),
y=uniform(loc=0.0, scale=1000.0), y=uniform(loc=0.0, scale=1000.0),
n=randint(low=100, high=101), n=randint(low=100, high=101),
gamma=uniform(loc=0.95, scale=0.1), gamma=uniform(loc=0.95, scale=0.1),
fix_cities=True, fix_cities=True,
).generate(100) ).generate(100)
assert len(instances) == 100 assert len(data) == 100
assert instances[0].n_cities == 100 assert data[0].n_cities == 100
assert norm(instances[0].distances - instances[0].distances.T) < 1e-6 assert norm(data[0].distances - data[0].distances.T) < 1e-6
d = [instance.distances[0, 1] for instance in instances] d = [d.distances[0, 1] for d in data]
assert np.std(d) > 0 assert np.std(d) > 0
@ -39,7 +40,7 @@ def test_instance() -> None:
) )
instance = TravelingSalesmanInstance(n_cities, distances) instance = TravelingSalesmanInstance(n_cities, distances)
solver = LearningSolver() solver = LearningSolver()
solver.solve(instance) solver._solve(instance)
assert len(instance.get_samples()) == 1 assert len(instance.get_samples()) == 1
sample = instance.get_samples()[0] 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]) assert_equals(sample.get_array("mip_var_values"), [1.0, 0.0, 1.0, 1.0, 0.0, 1.0])
@ -62,13 +63,19 @@ def test_subtour() -> None:
distances = squareform(pdist(cities)) distances = squareform(pdist(cities))
instance = TravelingSalesmanInstance(n_cities, distances) instance = TravelingSalesmanInstance(n_cities, distances)
solver = LearningSolver() solver = LearningSolver()
solver.solve(instance) solver._solve(instance)
samples = instance.get_samples() samples = instance.get_samples()
assert len(samples) == 1 assert len(samples) == 1
sample = samples[0] sample = samples[0]
lazy_enforced = sample.get_array("mip_constr_lazy_enforced")
assert lazy_enforced is not None lazy_encoded = sample.get_scalar("mip_constr_lazy")
assert len(lazy_enforced) > 0 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( assert_equals(
sample.get_array("mip_var_values"), sample.get_array("mip_var_values"),
[ [
@ -89,5 +96,5 @@ def test_subtour() -> None:
1.0, 1.0,
], ],
) )
solver.fit([instance]) solver._fit([instance])
solver.solve(instance) solver._solve(instance)

@ -5,19 +5,27 @@
import logging import logging
import os import os
import tempfile import tempfile
from os.path import exists
from typing import List, cast from typing import List, cast
import dill import dill
from scipy.stats import randint
from miplearn.features.sample import Hdf5Sample
from miplearn.instance.base import Instance from miplearn.instance.base import Instance
from miplearn.instance.picklegz import PickleGzInstance, write_pickle_gz, read_pickle_gz from miplearn.instance.picklegz import (
from miplearn.solvers.gurobi import GurobiSolver 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.internal import InternalSolver
from miplearn.solvers.learning import LearningSolver from miplearn.solvers.learning import LearningSolver
from miplearn.solvers.tests import assert_equals
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences
from tests.solvers.test_internal_solver import internal_solvers from tests.solvers.test_internal_solver import internal_solvers
from miplearn.solvers.tests import assert_equals
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -34,7 +42,7 @@ def test_learning_solver(
mode=mode, mode=mode,
) )
solver.solve(instance) solver._solve(instance)
assert len(instance.get_samples()) > 0 assert len(instance.get_samples()) > 0
sample = instance.get_samples()[0] sample = instance.get_samples()[0]
@ -55,8 +63,8 @@ def test_learning_solver(
assert lp_log is not None assert lp_log is not None
assert len(lp_log) > 100 assert len(lp_log) > 100
solver.fit([instance], n_jobs=4) solver._fit([instance], n_jobs=4)
solver.solve(instance) solver._solve(instance)
# Assert solver is picklable # Assert solver is picklable
with tempfile.TemporaryFile() as file: with tempfile.TemporaryFile() as file:
@ -73,9 +81,9 @@ def test_solve_without_lp(
solver=internal_solver, solver=internal_solver,
solve_lp=False, solve_lp=False,
) )
solver.solve(instance) solver._solve(instance)
solver.fit([instance]) solver._fit([instance])
solver.solve(instance) solver._solve(instance)
def test_parallel_solve( def test_parallel_solve(
@ -104,7 +112,7 @@ def test_solve_fit_from_disk(
# Test: solve # Test: solve
solver = LearningSolver(solver=internal_solver) solver = LearningSolver(solver=internal_solver)
solver.solve(instances[0]) solver._solve(instances[0])
instance_loaded = read_pickle_gz(cast(PickleGzInstance, instances[0]).filename) instance_loaded = read_pickle_gz(cast(PickleGzInstance, instances[0]).filename)
assert len(instance_loaded.get_samples()) > 0 assert len(instance_loaded.get_samples()) > 0
@ -119,17 +127,30 @@ def test_solve_fit_from_disk(
os.remove(cast(PickleGzInstance, instance).filename) os.remove(cast(PickleGzInstance, instance).filename)
def test_simulate_perfect() -> None: def test_basic_usage() -> None:
internal_solver = GurobiSolver() with tempfile.TemporaryDirectory() as dirname:
instance = internal_solver.build_test_instance_knapsack() # Generate instances
with tempfile.NamedTemporaryFile(suffix=".pkl", delete=False) as tmp: data = MaxWeightStableSetGenerator(n=randint(low=20, high=21)).generate(4)
write_pickle_gz(instance, tmp.name) train_files = save(data[0:3], f"{dirname}/train")
solver = LearningSolver( test_files = save(data[3:4], f"{dirname}/test")
solver=internal_solver,
simulate_perfect=True, # Solve training instances
) solver = LearningSolver()
stats = solver.solve(PickleGzInstance(tmp.name)) stats = solver.solve(train_files, build_stab_model)
assert stats["mip_lower_bound"] == stats["Objective: Predicted lower bound"] 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 isinstance(stats, list)
assert "Objective: Predicted lower bound" in stats[0].keys()
def test_gap() -> None: def test_gap() -> None:

@ -7,7 +7,10 @@ import os.path
from scipy.stats import randint from scipy.stats import randint
from miplearn.benchmark import BenchmarkRunner from miplearn.benchmark import BenchmarkRunner
from miplearn.problems.stab import MaxWeightStableSetGenerator from miplearn.problems.stab import (
MaxWeightStableSetInstance,
MaxWeightStableSetGenerator,
)
from miplearn.solvers.learning import LearningSolver from miplearn.solvers.learning import LearningSolver
@ -15,8 +18,14 @@ def test_benchmark() -> None:
for n_jobs in [1, 4]: for n_jobs in [1, 4]:
# Generate training and test instances # Generate training and test instances
generator = MaxWeightStableSetGenerator(n=randint(low=25, high=26)) generator = MaxWeightStableSetGenerator(n=randint(low=25, high=26))
train_instances = generator.generate(5) train_instances = [
test_instances = generator.generate(3) 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 # Solve training instances
training_solver = LearningSolver() training_solver = LearningSolver()

Loading…
Cancel
Save