Merge branch 'feature/hdf5' into dev

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

@ -33,6 +33,7 @@
)
```
- `LazyConstraintComponent` has been renamed to `DynamicLazyConstraintsComponent`.
- Categories, lazy constraints and cutting plane identifiers must now be strings, instead `Hashable`. This change was required for compatibility with HDF5 data format.
### Removed

@ -2,7 +2,7 @@ PYTHON := python3
PYTEST := pytest
PIP := $(PYTHON) -m pip
MYPY := $(PYTHON) -m mypy
PYTEST_ARGS := -W ignore::DeprecationWarning -vv -x --log-level=DEBUG
PYTEST_ARGS := -W ignore::DeprecationWarning -vv --log-level=DEBUG
VERSION := 0.2
all: docs test

@ -2,14 +2,14 @@
# 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 Any, List, TYPE_CHECKING, Tuple, Dict, Hashable, Optional
from typing import Any, List, TYPE_CHECKING, Tuple, Dict, Optional
import numpy as np
from p_tqdm import p_umap
from miplearn.features import Sample
from miplearn.features.sample import Sample
from miplearn.instance.base import Instance
from miplearn.types import LearningSolveStats
from miplearn.types import LearningSolveStats, Category
if TYPE_CHECKING:
from miplearn.solvers.learning import LearningSolver
@ -101,8 +101,8 @@ class Component:
def fit_xy(
self,
x: Dict[Hashable, np.ndarray],
y: Dict[Hashable, np.ndarray],
x: Dict[Category, np.ndarray],
y: Dict[Category, np.ndarray],
) -> None:
"""
Given two dictionaries x and y, mapping the name of the category to matrices
@ -152,7 +152,7 @@ class Component:
self,
instance: Optional[Instance],
sample: Sample,
) -> Dict[Hashable, Dict[str, float]]:
) -> Dict[str, Dict[str, float]]:
return {}
def sample_xy(

@ -3,17 +3,19 @@
# Released under the modified BSD license. See COPYING.md for more details.
import logging
from typing import Dict, Hashable, List, Tuple, Optional, Any, Set
from typing import Dict, List, Tuple, Optional, Any, Set
import numpy as np
from overrides import overrides
from miplearn.features.extractor import FeaturesExtractor
from miplearn.classifiers import Classifier
from miplearn.classifiers.threshold import Threshold
from miplearn.components import classifier_evaluation_dict
from miplearn.components.component import Component
from miplearn.features import Sample
from miplearn.features.sample import Sample
from miplearn.instance.base import Instance
from miplearn.types import ConstraintCategory, ConstraintName
logger = logging.getLogger(__name__)
@ -32,9 +34,9 @@ class DynamicConstraintsComponent(Component):
assert isinstance(classifier, Classifier)
self.threshold_prototype: Threshold = threshold
self.classifier_prototype: Classifier = classifier
self.classifiers: Dict[Hashable, Classifier] = {}
self.thresholds: Dict[Hashable, Threshold] = {}
self.known_cids: List[str] = []
self.classifiers: Dict[ConstraintCategory, Classifier] = {}
self.thresholds: Dict[ConstraintCategory, Threshold] = {}
self.known_cids: List[ConstraintName] = []
self.attr = attr
def sample_xy_with_cids(
@ -42,52 +44,48 @@ class DynamicConstraintsComponent(Component):
instance: Optional[Instance],
sample: Sample,
) -> Tuple[
Dict[Hashable, List[List[float]]],
Dict[Hashable, List[List[bool]]],
Dict[Hashable, List[str]],
Dict[ConstraintCategory, List[List[float]]],
Dict[ConstraintCategory, List[List[bool]]],
Dict[ConstraintCategory, List[ConstraintName]],
]:
if len(self.known_cids) == 0:
return {}, {}, {}
assert instance is not None
x: Dict[Hashable, List[List[float]]] = {}
y: Dict[Hashable, List[List[bool]]] = {}
cids: Dict[Hashable, List[str]] = {}
constr_categories_dict = instance.get_constraint_categories()
constr_features_dict = instance.get_constraint_features()
for cid in self.known_cids:
# Initialize categories
if cid in constr_categories_dict:
category = constr_categories_dict[cid]
else:
category = cid
if category is None:
continue
if category not in x:
x[category] = []
y[category] = []
cids[category] = []
# Features
features = []
assert sample.after_load is not None
assert sample.after_load.instance is not None
features.extend(sample.after_load.instance.to_list())
if cid in constr_features_dict:
features.extend(constr_features_dict[cid])
for ci in features:
assert isinstance(ci, float), (
f"Constraint features must be a list of floats. "
f"Found {ci.__class__.__name__} instead."
)
x[category].append(features)
cids[category].append(cid)
# Labels
if sample.after_mip is not None:
assert sample.after_mip.extra is not None
if sample.after_mip.extra[self.attr] is not None:
if cid in sample.after_mip.extra[self.attr]:
y[category] += [[False, True]]
else:
y[category] += [[True, False]]
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")
enforced_cids = None
enforced_cids_np = sample.get_array(self.attr)
if enforced_cids_np is not None:
enforced_cids = list(enforced_cids_np)
# Get user-provided constraint features
(
constr_features,
constr_categories,
constr_lazy,
) = FeaturesExtractor._extract_user_features_constrs(instance, known_cids)
# Augment with instance features
instance_features = sample.get_array("static_instance_features")
assert instance_features is not None
constr_features = np.hstack(
[
instance_features.reshape(1, -1).repeat(len(known_cids), axis=0),
constr_features,
]
)
categories = np.unique(constr_categories)
for c in categories:
x[c] = constr_features[constr_categories == c].tolist()
cids[c] = known_cids[constr_categories == c].tolist()
if enforced_cids is not None:
tmp = np.isin(cids[c], enforced_cids).reshape(-1, 1)
y[c] = np.hstack([~tmp, tmp]).tolist() # type: ignore
return x, y, cids
@overrides
@ -104,7 +102,7 @@ class DynamicConstraintsComponent(Component):
assert pre is not None
known_cids: Set = set()
for cids in pre:
known_cids |= cids
known_cids |= set(list(cids))
self.known_cids.clear()
self.known_cids.extend(sorted(known_cids))
@ -112,8 +110,8 @@ class DynamicConstraintsComponent(Component):
self,
instance: Instance,
sample: Sample,
) -> List[Hashable]:
pred: List[Hashable] = []
) -> List[ConstraintName]:
pred: List[ConstraintName] = []
if len(self.known_cids) == 0:
logger.info("Classifiers not fitted. Skipping.")
return pred
@ -133,19 +131,13 @@ class DynamicConstraintsComponent(Component):
@overrides
def pre_sample_xy(self, instance: Instance, sample: Sample) -> Any:
if (
sample.after_mip is None
or sample.after_mip.extra is None
or sample.after_mip.extra[self.attr] is None
):
return
return sample.after_mip.extra[self.attr]
return sample.get_array(self.attr)
@overrides
def fit_xy(
self,
x: Dict[Hashable, np.ndarray],
y: Dict[Hashable, np.ndarray],
x: Dict[ConstraintCategory, np.ndarray],
y: Dict[ConstraintCategory, np.ndarray],
) -> None:
for category in x.keys():
self.classifiers[category] = self.classifier_prototype.clone()
@ -160,42 +152,20 @@ class DynamicConstraintsComponent(Component):
self,
instance: Instance,
sample: Sample,
) -> Dict[Hashable, Dict[str, float]]:
assert sample.after_mip is not None
assert sample.after_mip.extra is not None
assert self.attr in sample.after_mip.extra
actual = sample.after_mip.extra[self.attr]
) -> Dict[str, float]:
actual = sample.get_array(self.attr)
assert actual is not None
pred = set(self.sample_predict(instance, sample))
tp: Dict[Hashable, int] = {}
tn: Dict[Hashable, int] = {}
fp: Dict[Hashable, int] = {}
fn: Dict[Hashable, int] = {}
constr_categories_dict = instance.get_constraint_categories()
tp, tn, fp, fn = 0, 0, 0, 0
for cid in self.known_cids:
if cid not in constr_categories_dict:
continue
category = constr_categories_dict[cid]
if category not in tp.keys():
tp[category] = 0
tn[category] = 0
fp[category] = 0
fn[category] = 0
if cid in pred:
if cid in actual:
tp[category] += 1
tp += 1
else:
fp[category] += 1
fp += 1
else:
if cid in actual:
fn[category] += 1
fn += 1
else:
tn[category] += 1
return {
category: classifier_evaluation_dict(
tp=tp[category],
tn=tn[category],
fp=fp[category],
fn=fn[category],
)
for category in tp.keys()
}
tn += 1
return classifier_evaluation_dict(tp=tp, tn=tn, fp=fp, fn=fn)

@ -3,7 +3,8 @@
# Released under the modified BSD license. See COPYING.md for more details.
import logging
from typing import Dict, List, TYPE_CHECKING, Hashable, Tuple, Any, Optional, Set
import pdb
from typing import Dict, List, TYPE_CHECKING, Tuple, Any, Optional, Set
import numpy as np
from overrides import overrides
@ -13,9 +14,9 @@ from miplearn.classifiers.counting import CountingClassifier
from miplearn.classifiers.threshold import MinProbabilityThreshold, Threshold
from miplearn.components.component import Component
from miplearn.components.dynamic_common import DynamicConstraintsComponent
from miplearn.features import Sample
from miplearn.features.sample import Sample
from miplearn.instance.base import Instance
from miplearn.types import LearningSolveStats
from miplearn.types import LearningSolveStats, ConstraintName, ConstraintCategory
logger = logging.getLogger(__name__)
@ -36,16 +37,16 @@ class DynamicLazyConstraintsComponent(Component):
self.dynamic: DynamicConstraintsComponent = DynamicConstraintsComponent(
classifier=classifier,
threshold=threshold,
attr="lazy_enforced",
attr="mip_constr_lazy_enforced",
)
self.classifiers = self.dynamic.classifiers
self.thresholds = self.dynamic.thresholds
self.known_cids = self.dynamic.known_cids
self.lazy_enforced: Set[Hashable] = set()
self.lazy_enforced: Set[ConstraintName] = set()
@staticmethod
def enforce(
cids: List[Hashable],
cids: List[ConstraintName],
instance: Instance,
model: Any,
solver: "LearningSolver",
@ -78,9 +79,10 @@ class DynamicLazyConstraintsComponent(Component):
stats: LearningSolveStats,
sample: Sample,
) -> None:
assert sample.after_mip is not None
assert sample.after_mip.extra is not None
sample.after_mip.extra["lazy_enforced"] = set(self.lazy_enforced)
sample.put_array(
"mip_constr_lazy_enforced",
np.array(list(self.lazy_enforced), dtype="S"),
)
@overrides
def iteration_cb(
@ -119,7 +121,7 @@ class DynamicLazyConstraintsComponent(Component):
self,
instance: Instance,
sample: Sample,
) -> List[Hashable]:
) -> List[ConstraintName]:
return self.dynamic.sample_predict(instance, sample)
@overrides
@ -129,8 +131,8 @@ class DynamicLazyConstraintsComponent(Component):
@overrides
def fit_xy(
self,
x: Dict[Hashable, np.ndarray],
y: Dict[Hashable, np.ndarray],
x: Dict[ConstraintCategory, np.ndarray],
y: Dict[ConstraintCategory, np.ndarray],
) -> None:
self.dynamic.fit_xy(x, y)
@ -139,5 +141,5 @@ class DynamicLazyConstraintsComponent(Component):
self,
instance: Instance,
sample: Sample,
) -> Dict[Hashable, Dict[str, float]]:
) -> Dict[ConstraintCategory, Dict[str, float]]:
return self.dynamic.sample_evaluate(instance, sample)

@ -3,7 +3,7 @@
# Released under the modified BSD license. See COPYING.md for more details.
import logging
from typing import Any, TYPE_CHECKING, Hashable, Set, Tuple, Dict, List, Optional
from typing import Any, TYPE_CHECKING, Set, Tuple, Dict, List, Optional
import numpy as np
from overrides import overrides
@ -13,9 +13,9 @@ from miplearn.classifiers.counting import CountingClassifier
from miplearn.classifiers.threshold import Threshold, MinProbabilityThreshold
from miplearn.components.component import Component
from miplearn.components.dynamic_common import DynamicConstraintsComponent
from miplearn.features import Sample
from miplearn.features.sample import Sample
from miplearn.instance.base import Instance
from miplearn.types import LearningSolveStats
from miplearn.types import LearningSolveStats, ConstraintName, ConstraintCategory
logger = logging.getLogger(__name__)
@ -32,9 +32,9 @@ class UserCutsComponent(Component):
self.dynamic = DynamicConstraintsComponent(
classifier=classifier,
threshold=threshold,
attr="user_cuts_enforced",
attr="mip_user_cuts_enforced",
)
self.enforced: Set[Hashable] = set()
self.enforced: Set[ConstraintName] = set()
self.n_added_in_callback = 0
@overrides
@ -71,7 +71,7 @@ class UserCutsComponent(Component):
for cid in cids:
if cid in self.enforced:
continue
assert isinstance(cid, Hashable)
assert isinstance(cid, ConstraintName)
instance.enforce_user_cut(solver.internal_solver, model, cid)
self.enforced.add(cid)
self.n_added_in_callback += 1
@ -87,9 +87,10 @@ class UserCutsComponent(Component):
stats: LearningSolveStats,
sample: Sample,
) -> None:
assert sample.after_mip is not None
assert sample.after_mip.extra is not None
sample.after_mip.extra["user_cuts_enforced"] = set(self.enforced)
sample.put_array(
"mip_user_cuts_enforced",
np.array(list(self.enforced), dtype="S"),
)
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")
@ -112,7 +113,7 @@ class UserCutsComponent(Component):
self,
instance: "Instance",
sample: Sample,
) -> List[Hashable]:
) -> List[ConstraintName]:
return self.dynamic.sample_predict(instance, sample)
@overrides
@ -122,8 +123,8 @@ class UserCutsComponent(Component):
@overrides
def fit_xy(
self,
x: Dict[Hashable, np.ndarray],
y: Dict[Hashable, np.ndarray],
x: Dict[ConstraintCategory, np.ndarray],
y: Dict[ConstraintCategory, np.ndarray],
) -> None:
self.dynamic.fit_xy(x, y)
@ -132,5 +133,5 @@ class UserCutsComponent(Component):
self,
instance: "Instance",
sample: Sample,
) -> Dict[Hashable, Dict[str, float]]:
) -> Dict[ConstraintCategory, Dict[str, float]]:
return self.dynamic.sample_evaluate(instance, sample)

@ -3,7 +3,7 @@
# Released under the modified BSD license. See COPYING.md for more details.
import logging
from typing import List, Dict, Any, TYPE_CHECKING, Tuple, Hashable, Optional
from typing import List, Dict, Any, TYPE_CHECKING, Tuple, Optional, cast
import numpy as np
from overrides import overrides
@ -12,7 +12,7 @@ from sklearn.linear_model import LinearRegression
from miplearn.classifiers import Regressor
from miplearn.classifiers.sklearn import ScikitLearnRegressor
from miplearn.components.component import Component
from miplearn.features import Sample
from miplearn.features.sample import Sample
from miplearn.instance.base import Instance
from miplearn.types import LearningSolveStats
@ -53,8 +53,8 @@ class ObjectiveValueComponent(Component):
@overrides
def fit_xy(
self,
x: Dict[Hashable, np.ndarray],
y: Dict[Hashable, np.ndarray],
x: Dict[str, np.ndarray],
y: Dict[str, np.ndarray],
) -> None:
for c in ["Upper bound", "Lower bound"]:
if c in y:
@ -76,32 +76,27 @@ class ObjectiveValueComponent(Component):
self,
_: Optional[Instance],
sample: Sample,
) -> Tuple[Dict[Hashable, List[List[float]]], Dict[Hashable, List[List[float]]]]:
# Instance features
assert sample.after_load is not None
assert sample.after_load.instance is not None
f = sample.after_load.instance.to_list()
# LP solve features
if sample.after_lp is not None:
assert sample.after_lp.lp_solve is not None
f.extend(sample.after_lp.lp_solve.to_list())
) -> Tuple[Dict[str, List[List[float]]], Dict[str, List[List[float]]]]:
lp_instance_features_np = sample.get_array("lp_instance_features")
if lp_instance_features_np is None:
lp_instance_features_np = sample.get_array("static_instance_features")
assert lp_instance_features_np is not None
lp_instance_features = cast(List[float], lp_instance_features_np.tolist())
# Features
x: Dict[Hashable, List[List[float]]] = {
"Upper bound": [f],
"Lower bound": [f],
x: Dict[str, List[List[float]]] = {
"Upper bound": [lp_instance_features],
"Lower bound": [lp_instance_features],
}
# Labels
y: Dict[Hashable, List[List[float]]] = {}
if sample.after_mip is not None:
mip_stats = sample.after_mip.mip_solve
assert mip_stats is not None
if mip_stats.mip_lower_bound is not None:
y["Lower bound"] = [[mip_stats.mip_lower_bound]]
if mip_stats.mip_upper_bound is not None:
y["Upper bound"] = [[mip_stats.mip_upper_bound]]
y: Dict[str, List[List[float]]] = {}
mip_lower_bound = sample.get_scalar("mip_lower_bound")
mip_upper_bound = sample.get_scalar("mip_upper_bound")
if mip_lower_bound is not None:
y["Lower bound"] = [[mip_lower_bound]]
if mip_upper_bound is not None:
y["Upper bound"] = [[mip_upper_bound]]
return x, y
@ -110,10 +105,7 @@ class ObjectiveValueComponent(Component):
self,
instance: Instance,
sample: Sample,
) -> Dict[Hashable, Dict[str, float]]:
assert sample.after_mip is not None
assert sample.after_mip.mip_solve is not None
) -> Dict[str, Dict[str, float]]:
def compare(y_pred: float, y_actual: float) -> Dict[str, float]:
err = np.round(abs(y_pred - y_actual), 8)
return {
@ -123,10 +115,10 @@ class ObjectiveValueComponent(Component):
"Relative error": err / y_actual,
}
result: Dict[Hashable, Dict[str, float]] = {}
result: Dict[str, Dict[str, float]] = {}
pred = self.sample_predict(sample)
actual_ub = sample.after_mip.mip_solve.mip_upper_bound
actual_lb = sample.after_mip.mip_solve.mip_lower_bound
actual_ub = sample.get_scalar("mip_upper_bound")
actual_lb = sample.get_scalar("mip_lower_bound")
if actual_ub is not None:
result["Upper bound"] = compare(pred["Upper bound"], actual_ub)
if actual_lb is not None:

@ -3,15 +3,7 @@
# Released under the modified BSD license. See COPYING.md for more details.
import logging
from typing import (
Dict,
List,
Hashable,
Any,
TYPE_CHECKING,
Tuple,
Optional,
)
from typing import Dict, List, Any, TYPE_CHECKING, Tuple, Optional
import numpy as np
from overrides import overrides
@ -21,7 +13,7 @@ from miplearn.classifiers.adaptive import AdaptiveClassifier
from miplearn.classifiers.threshold import MinPrecisionThreshold, Threshold
from miplearn.components import classifier_evaluation_dict
from miplearn.components.component import Component
from miplearn.features import Sample
from miplearn.features.sample import Sample
from miplearn.instance.base import Instance
from miplearn.types import (
LearningSolveStats,
@ -55,8 +47,8 @@ class PrimalSolutionComponent(Component):
assert isinstance(threshold, Threshold)
assert mode in ["exact", "heuristic"]
self.mode = mode
self.classifiers: Dict[Hashable, Classifier] = {}
self.thresholds: Dict[Hashable, Threshold] = {}
self.classifiers: Dict[Category, Classifier] = {}
self.thresholds: Dict[Category, Threshold] = {}
self.threshold_prototype = threshold
self.classifier_prototype = classifier
@ -103,8 +95,10 @@ class PrimalSolutionComponent(Component):
)
def sample_predict(self, sample: Sample) -> Solution:
assert sample.after_load is not None
assert sample.after_load.variables is not None
var_names = sample.get_array("static_var_names")
var_categories = sample.get_array("static_var_categories")
assert var_names is not None
assert var_categories is not None
# Compute y_pred
x, _ = self.sample_xy(None, sample)
@ -125,12 +119,10 @@ class PrimalSolutionComponent(Component):
).T
# Convert y_pred into solution
assert sample.after_load.variables.names is not None
assert sample.after_load.variables.categories is not None
solution: Solution = {v: None for v in sample.after_load.variables.names}
category_offset: Dict[Hashable, int] = {cat: 0 for cat in x.keys()}
for (i, var_name) in enumerate(sample.after_load.variables.names):
category = sample.after_load.variables.categories[i]
solution: Solution = {v: None for v in var_names}
category_offset: Dict[Category, int] = {cat: 0 for cat in x.keys()}
for (i, var_name) in enumerate(var_names):
category = var_categories[i]
if category not in category_offset:
continue
offset = category_offset[category]
@ -150,40 +142,41 @@ class PrimalSolutionComponent(Component):
) -> Tuple[Dict[Category, List[List[float]]], Dict[Category, List[List[float]]]]:
x: Dict = {}
y: Dict = {}
assert sample.after_load is not None
assert sample.after_load.instance is not None
assert sample.after_load.variables is not None
assert sample.after_load.variables.names is not None
assert sample.after_load.variables.categories is not None
for (i, var_name) in enumerate(sample.after_load.variables.names):
instance_features = sample.get_array("static_instance_features")
mip_var_values = sample.get_array("mip_var_values")
var_features = sample.get_array("lp_var_features")
var_names = sample.get_array("static_var_names")
var_categories = sample.get_array("static_var_categories")
if var_features is None:
var_features = sample.get_array("static_var_features")
assert instance_features is not None
assert var_features is not None
assert var_names is not None
assert var_categories is not None
for (i, var_name) in enumerate(var_names):
# Initialize categories
category = sample.after_load.variables.categories[i]
if category is None:
category = var_categories[i]
if len(category) == 0:
continue
if category not in x.keys():
x[category] = []
y[category] = []
# Features
features = list(sample.after_load.instance.to_list())
features.extend(sample.after_load.variables.to_list(i))
if sample.after_lp is not None:
assert sample.after_lp.variables is not None
features.extend(sample.after_lp.variables.to_list(i))
features = list(instance_features)
features.extend(var_features[i])
x[category].append(features)
# Labels
if sample.after_mip is not None:
assert sample.after_mip.variables is not None
assert sample.after_mip.variables.values is not None
opt_value = sample.after_mip.variables.values[i]
if mip_var_values is not None:
opt_value = mip_var_values[i]
assert opt_value is not None
assert 0.0 - 1e-5 <= opt_value <= 1.0 + 1e-5, (
f"Variable {var_name} has non-binary value {opt_value} in the "
"optimal solution. Predicting values of non-binary "
"variables is not currently supported. Please set its "
"category to None."
"category to ''."
)
y[category].append([opt_value < 0.5, opt_value >= 0.5])
return x, y
@ -193,15 +186,14 @@ class PrimalSolutionComponent(Component):
self,
_: Optional[Instance],
sample: Sample,
) -> Dict[Hashable, Dict[str, float]]:
assert sample.after_mip is not None
assert sample.after_mip.variables is not None
assert sample.after_mip.variables.values is not None
assert sample.after_mip.variables.names is not None
) -> Dict[str, Dict[str, float]]:
mip_var_values = sample.get_array("mip_var_values")
var_names = sample.get_array("static_var_names")
assert mip_var_values is not None
assert var_names is not None
solution_actual = {
var_name: sample.after_mip.variables.values[i]
for (i, var_name) in enumerate(sample.after_mip.variables.names)
var_name: mip_var_values[i] for (i, var_name) in enumerate(var_names)
}
solution_pred = self.sample_predict(sample)
vars_all, vars_one, vars_zero = set(), set(), set()
@ -221,13 +213,13 @@ class PrimalSolutionComponent(Component):
pred_one_negative = vars_all - pred_one_positive
pred_zero_negative = vars_all - pred_zero_positive
return {
0: classifier_evaluation_dict(
"0": classifier_evaluation_dict(
tp=len(pred_zero_positive & vars_zero),
tn=len(pred_zero_negative & vars_one),
fp=len(pred_zero_positive & vars_one),
fn=len(pred_zero_negative & vars_zero),
),
1: classifier_evaluation_dict(
"1": classifier_evaluation_dict(
tp=len(pred_one_positive & vars_one),
tn=len(pred_one_negative & vars_zero),
fp=len(pred_one_positive & vars_zero),
@ -238,8 +230,8 @@ class PrimalSolutionComponent(Component):
@overrides
def fit_xy(
self,
x: Dict[Hashable, np.ndarray],
y: Dict[Hashable, np.ndarray],
x: Dict[Category, np.ndarray],
y: Dict[Category, np.ndarray],
) -> None:
for category in x.keys():
clf = self.classifier_prototype.clone()

@ -3,7 +3,7 @@
# Released under the modified BSD license. See COPYING.md for more details.
import logging
from typing import Dict, Tuple, List, Hashable, Any, TYPE_CHECKING, Set, Optional
from typing import Dict, Tuple, List, Any, TYPE_CHECKING, Set, Optional
import numpy as np
from overrides import overrides
@ -12,9 +12,10 @@ from miplearn.classifiers import Classifier
from miplearn.classifiers.counting import CountingClassifier
from miplearn.classifiers.threshold import MinProbabilityThreshold, Threshold
from miplearn.components.component import Component
from miplearn.features import Sample, ConstraintFeatures
from miplearn.features.sample import Sample
from miplearn.solvers.internal import Constraints
from miplearn.instance.base import Instance
from miplearn.types import LearningSolveStats
from miplearn.types import LearningSolveStats, ConstraintName, ConstraintCategory
logger = logging.getLogger(__name__)
@ -23,7 +24,7 @@ if TYPE_CHECKING:
class LazyConstraint:
def __init__(self, cid: str, obj: Any) -> None:
def __init__(self, cid: ConstraintName, obj: Any) -> None:
self.cid = cid
self.obj = obj
@ -43,11 +44,11 @@ class StaticLazyConstraintsComponent(Component):
assert isinstance(classifier, Classifier)
self.classifier_prototype: Classifier = classifier
self.threshold_prototype: Threshold = threshold
self.classifiers: Dict[Hashable, Classifier] = {}
self.thresholds: Dict[Hashable, Threshold] = {}
self.pool: ConstraintFeatures = ConstraintFeatures()
self.classifiers: Dict[ConstraintCategory, Classifier] = {}
self.thresholds: Dict[ConstraintCategory, Threshold] = {}
self.pool: Constraints = Constraints()
self.violation_tolerance: float = violation_tolerance
self.enforced_cids: Set[Hashable] = set()
self.enforced_cids: Set[ConstraintName] = set()
self.n_restored: int = 0
self.n_iterations: int = 0
@ -60,9 +61,10 @@ class StaticLazyConstraintsComponent(Component):
stats: LearningSolveStats,
sample: Sample,
) -> None:
assert sample.after_mip is not None
assert sample.after_mip.extra is not None
sample.after_mip.extra["lazy_enforced"] = self.enforced_cids
sample.put_array(
"mip_constr_lazy_enforced",
np.array(list(self.enforced_cids), dtype="S"),
)
stats["LazyStatic: Restored"] = self.n_restored
stats["LazyStatic: Iterations"] = self.n_iterations
@ -76,16 +78,15 @@ class StaticLazyConstraintsComponent(Component):
sample: Sample,
) -> None:
assert solver.internal_solver is not None
assert sample.after_load is not None
assert sample.after_load.instance is not None
static_lazy_count = sample.get_scalar("static_constr_lazy_count")
assert static_lazy_count is not None
logger.info("Predicting violated (static) lazy constraints...")
if sample.after_load.instance.lazy_constraint_count == 0:
if static_lazy_count == 0:
logger.info("Instance does not have static lazy constraints. Skipping.")
self.enforced_cids = set(self.sample_predict(sample))
logger.info("Moving lazy constraints to the pool...")
constraints = sample.after_load.constraints
assert constraints is not None
constraints = Constraints.from_sample(sample)
assert constraints.lazy is not None
assert constraints.names is not None
selected = [
@ -107,8 +108,8 @@ class StaticLazyConstraintsComponent(Component):
@overrides
def fit_xy(
self,
x: Dict[Hashable, np.ndarray],
y: Dict[Hashable, np.ndarray],
x: Dict[ConstraintCategory, np.ndarray],
y: Dict[ConstraintCategory, np.ndarray],
) -> None:
for c in y.keys():
assert c in x
@ -138,9 +139,9 @@ class StaticLazyConstraintsComponent(Component):
) -> None:
self._check_and_add(solver)
def sample_predict(self, sample: Sample) -> List[Hashable]:
def sample_predict(self, sample: Sample) -> List[ConstraintName]:
x, y, cids = self._sample_xy_with_cids(sample)
enforced_cids: List[Hashable] = []
enforced_cids: List[ConstraintName] = []
for category in x.keys():
if category not in self.classifiers:
continue
@ -158,7 +159,10 @@ class StaticLazyConstraintsComponent(Component):
self,
_: Optional[Instance],
sample: Sample,
) -> Tuple[Dict[Hashable, List[List[float]]], Dict[Hashable, List[List[float]]]]:
) -> Tuple[
Dict[ConstraintCategory, List[List[float]]],
Dict[ConstraintCategory, List[List[float]]],
]:
x, y, __ = self._sample_xy_with_cids(sample)
return x, y
@ -185,7 +189,7 @@ class StaticLazyConstraintsComponent(Component):
logger.info(f"Found {n_violated} violated lazy constraints found")
if n_violated > 0:
logger.info(
"Enforcing {n_violated} lazy constraints; "
f"Enforcing {n_violated} lazy constraints; "
f"{n_satisfied} left in the pool..."
)
solver.internal_solver.add_constraints(violated_constraints)
@ -199,25 +203,34 @@ class StaticLazyConstraintsComponent(Component):
def _sample_xy_with_cids(
self, sample: Sample
) -> Tuple[
Dict[Hashable, List[List[float]]],
Dict[Hashable, List[List[float]]],
Dict[Hashable, List[str]],
Dict[ConstraintCategory, List[List[float]]],
Dict[ConstraintCategory, List[List[float]]],
Dict[ConstraintCategory, List[ConstraintName]],
]:
x: Dict[Hashable, List[List[float]]] = {}
y: Dict[Hashable, List[List[float]]] = {}
cids: Dict[Hashable, List[str]] = {}
assert sample.after_load is not None
constraints = sample.after_load.constraints
assert constraints is not None
assert constraints.names is not None
assert constraints.lazy is not None
assert constraints.categories is not None
for (cidx, cname) in enumerate(constraints.names):
x: Dict[ConstraintCategory, List[List[float]]] = {}
y: Dict[ConstraintCategory, List[List[float]]] = {}
cids: Dict[ConstraintCategory, List[ConstraintName]] = {}
instance_features = sample.get_array("static_instance_features")
constr_features = sample.get_array("lp_constr_features")
constr_names = sample.get_array("static_constr_names")
constr_categories = sample.get_array("static_constr_categories")
constr_lazy = sample.get_array("static_constr_lazy")
lazy_enforced = sample.get_array("mip_constr_lazy_enforced")
if constr_features is None:
constr_features = sample.get_array("static_constr_features")
assert instance_features is not None
assert constr_features is not None
assert constr_names is not None
assert constr_categories is not None
assert constr_lazy is not None
for (cidx, cname) in enumerate(constr_names):
# Initialize categories
if not constraints.lazy[cidx]:
if not constr_lazy[cidx]:
continue
category = constraints.categories[cidx]
if category is None:
category = constr_categories[cidx]
if len(category) == 0:
continue
if category not in x:
x[category] = []
@ -225,23 +238,14 @@ class StaticLazyConstraintsComponent(Component):
cids[category] = []
# Features
sf = sample.after_load
if sample.after_lp is not None:
sf = sample.after_lp
assert sf.instance is not None
assert sf.constraints is not None
features = list(sf.instance.to_list())
features.extend(sf.constraints.to_list(cidx))
features = list(instance_features)
features.extend(constr_features[cidx])
x[category].append(features)
cids[category].append(cname)
# Labels
if (
(sample.after_mip is not None)
and (sample.after_mip.extra is not None)
and ("lazy_enforced" in sample.after_mip.extra)
):
if cname in sample.after_mip.extra["lazy_enforced"]:
if lazy_enforced is not None:
if cname in lazy_enforced:
y[category] += [[False, True]]
else:
y[category] += [[True, False]]

@ -1,384 +0,0 @@
# 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 collections
import numbers
from dataclasses import dataclass
from math import log, isfinite
from typing import TYPE_CHECKING, Dict, Optional, List, Hashable, Tuple
import numpy as np
if TYPE_CHECKING:
from miplearn.solvers.internal import InternalSolver, LPSolveStats, MIPSolveStats
from miplearn.instance.base import Instance
@dataclass
class InstanceFeatures:
user_features: Optional[List[float]] = None
lazy_constraint_count: int = 0
def to_list(self) -> List[float]:
features: List[float] = []
if self.user_features is not None:
features.extend(self.user_features)
_clip(features)
return features
@dataclass
class VariableFeatures:
names: Optional[List[str]] = None
basis_status: Optional[List[str]] = None
categories: Optional[List[Optional[Hashable]]] = None
lower_bounds: Optional[List[float]] = None
obj_coeffs: Optional[List[float]] = None
reduced_costs: Optional[List[float]] = None
sa_lb_down: Optional[List[float]] = None
sa_lb_up: Optional[List[float]] = None
sa_obj_down: Optional[List[float]] = None
sa_obj_up: Optional[List[float]] = None
sa_ub_down: Optional[List[float]] = None
sa_ub_up: Optional[List[float]] = None
types: Optional[List[str]] = None
upper_bounds: Optional[List[float]] = None
user_features: Optional[List[Optional[List[float]]]] = None
values: Optional[List[float]] = None
# Alvarez, A. M., Louveaux, Q., & Wehenkel, L. (2017). A machine learning-based
# approximation of strong branching. INFORMS Journal on Computing, 29(1), 185-195.
alvarez_2017: Optional[List[List[float]]] = None
def to_list(self, index: int) -> List[float]:
features: List[float] = []
for attr in [
"lower_bounds",
"obj_coeffs",
"reduced_costs",
"sa_lb_down",
"sa_lb_up",
"sa_obj_down",
"sa_obj_up",
"sa_ub_down",
"sa_ub_up",
"upper_bounds",
"values",
]:
if getattr(self, attr) is not None:
features.append(getattr(self, attr)[index])
for attr in ["user_features", "alvarez_2017"]:
if getattr(self, attr) is not None:
if getattr(self, attr)[index] is not None:
features.extend(getattr(self, attr)[index])
_clip(features)
return features
@dataclass
class ConstraintFeatures:
basis_status: Optional[List[str]] = None
categories: Optional[List[Optional[Hashable]]] = None
dual_values: Optional[List[float]] = None
names: Optional[List[str]] = None
lazy: Optional[List[bool]] = None
lhs: Optional[List[List[Tuple[str, float]]]] = None
rhs: Optional[List[float]] = None
sa_rhs_down: Optional[List[float]] = None
sa_rhs_up: Optional[List[float]] = None
senses: Optional[List[str]] = None
slacks: Optional[List[float]] = None
user_features: Optional[List[Optional[List[float]]]] = None
def to_list(self, index: int) -> List[float]:
features: List[float] = []
for attr in [
"dual_values",
"rhs",
"slacks",
]:
if getattr(self, attr) is not None:
features.append(getattr(self, attr)[index])
for attr in ["user_features"]:
if getattr(self, attr) is not None:
if getattr(self, attr)[index] is not None:
features.extend(getattr(self, attr)[index])
_clip(features)
return features
def __getitem__(self, selected: List[bool]) -> "ConstraintFeatures":
return ConstraintFeatures(
basis_status=self._filter(self.basis_status, selected),
categories=self._filter(self.categories, selected),
dual_values=self._filter(self.dual_values, selected),
names=self._filter(self.names, selected),
lazy=self._filter(self.lazy, selected),
lhs=self._filter(self.lhs, selected),
rhs=self._filter(self.rhs, selected),
sa_rhs_down=self._filter(self.sa_rhs_down, selected),
sa_rhs_up=self._filter(self.sa_rhs_up, selected),
senses=self._filter(self.senses, selected),
slacks=self._filter(self.slacks, selected),
user_features=self._filter(self.user_features, selected),
)
def _filter(
self,
obj: Optional[List],
selected: List[bool],
) -> Optional[List]:
if obj is None:
return None
return [obj[i] for (i, selected_i) in enumerate(selected) if selected_i]
@dataclass
class Features:
instance: Optional[InstanceFeatures] = None
variables: Optional[VariableFeatures] = None
constraints: Optional[ConstraintFeatures] = None
lp_solve: Optional["LPSolveStats"] = None
mip_solve: Optional["MIPSolveStats"] = None
extra: Optional[Dict] = None
@dataclass
class Sample:
after_load: Optional[Features] = None
after_lp: Optional[Features] = None
after_mip: Optional[Features] = None
class FeaturesExtractor:
def __init__(
self,
with_sa: bool = True,
with_lhs: bool = True,
) -> None:
self.with_sa = with_sa
self.with_lhs = with_lhs
def extract(
self,
instance: "Instance",
solver: "InternalSolver",
with_static: bool = True,
) -> Features:
features = Features()
features.variables = solver.get_variables(
with_static=with_static,
with_sa=self.with_sa,
)
features.constraints = solver.get_constraints(
with_static=with_static,
with_sa=self.with_sa,
with_lhs=self.with_lhs,
)
if with_static:
self._extract_user_features_vars(instance, features)
self._extract_user_features_constrs(instance, features)
self._extract_user_features_instance(instance, features)
self._extract_alvarez_2017(features)
return features
def _extract_user_features_vars(
self,
instance: "Instance",
features: Features,
) -> None:
assert features.variables is not None
assert features.variables.names is not None
categories: List[Optional[Hashable]] = []
user_features: List[Optional[List[float]]] = []
var_features_dict = instance.get_variable_features()
var_categories_dict = instance.get_variable_categories()
for (i, var_name) in enumerate(features.variables.names):
if var_name not in var_categories_dict:
user_features.append(None)
categories.append(None)
continue
category: Hashable = var_categories_dict[var_name]
assert isinstance(category, collections.Hashable), (
f"Variable category must be be hashable. "
f"Found {type(category).__name__} instead for var={var_name}."
)
categories.append(category)
user_features_i: Optional[List[float]] = None
if var_name in var_features_dict:
user_features_i = var_features_dict[var_name]
if isinstance(user_features_i, np.ndarray):
user_features_i = user_features_i.tolist()
assert isinstance(user_features_i, list), (
f"Variable features must be a list. "
f"Found {type(user_features_i).__name__} instead for "
f"var={var_name}."
)
for v in user_features_i:
assert isinstance(v, numbers.Real), (
f"Variable features must be a list of numbers. "
f"Found {type(v).__name__} instead "
f"for var={var_name}."
)
user_features_i = list(user_features_i)
user_features.append(user_features_i)
features.variables.categories = categories
features.variables.user_features = user_features
def _extract_user_features_constrs(
self,
instance: "Instance",
features: Features,
) -> None:
assert features.constraints is not None
assert features.constraints.names is not None
has_static_lazy = instance.has_static_lazy_constraints()
user_features: List[Optional[List[float]]] = []
categories: List[Optional[Hashable]] = []
lazy: List[bool] = []
constr_categories_dict = instance.get_constraint_categories()
constr_features_dict = instance.get_constraint_features()
for (cidx, cname) in enumerate(features.constraints.names):
category: Optional[Hashable] = cname
if cname in constr_categories_dict:
category = constr_categories_dict[cname]
if category is None:
user_features.append(None)
categories.append(None)
continue
assert isinstance(category, collections.Hashable), (
f"Constraint category must be hashable. "
f"Found {type(category).__name__} instead for cname={cname}.",
)
categories.append(category)
cf: Optional[List[float]] = None
if cname in constr_features_dict:
cf = constr_features_dict[cname]
if isinstance(cf, np.ndarray):
cf = cf.tolist()
assert isinstance(cf, list), (
f"Constraint features must be a list. "
f"Found {type(cf).__name__} instead for cname={cname}."
)
for f in cf:
assert isinstance(f, numbers.Real), (
f"Constraint features must be a list of numbers. "
f"Found {type(f).__name__} instead for cname={cname}."
)
cf = list(cf)
user_features.append(cf)
if has_static_lazy:
lazy.append(instance.is_constraint_lazy(cname))
else:
lazy.append(False)
features.constraints.user_features = user_features
features.constraints.lazy = lazy
features.constraints.categories = categories
def _extract_user_features_instance(
self,
instance: "Instance",
features: Features,
) -> None:
user_features = instance.get_instance_features()
if isinstance(user_features, np.ndarray):
user_features = user_features.tolist()
assert isinstance(user_features, list), (
f"Instance features must be a list. "
f"Found {type(user_features).__name__} instead."
)
for v in user_features:
assert isinstance(v, numbers.Real), (
f"Instance features must be a list of numbers. "
f"Found {type(v).__name__} instead."
)
assert features.constraints is not None
assert features.constraints.lazy is not None
features.instance = InstanceFeatures(
user_features=user_features,
lazy_constraint_count=sum(features.constraints.lazy),
)
def _extract_alvarez_2017(self, features: Features) -> None:
assert features.variables is not None
assert features.variables.names is not None
obj_coeffs = features.variables.obj_coeffs
obj_sa_down = features.variables.sa_obj_down
obj_sa_up = features.variables.sa_obj_up
values = features.variables.values
pos_obj_coeff_sum = 0.0
neg_obj_coeff_sum = 0.0
if obj_coeffs is not None:
for coeff in obj_coeffs:
if coeff > 0:
pos_obj_coeff_sum += coeff
if coeff < 0:
neg_obj_coeff_sum += -coeff
features.variables.alvarez_2017 = []
for i in range(len(features.variables.names)):
f: List[float] = []
if obj_coeffs is not None:
# Feature 1
f.append(np.sign(obj_coeffs[i]))
# Feature 2
if pos_obj_coeff_sum > 0:
f.append(abs(obj_coeffs[i]) / pos_obj_coeff_sum)
else:
f.append(0.0)
# Feature 3
if neg_obj_coeff_sum > 0:
f.append(abs(obj_coeffs[i]) / neg_obj_coeff_sum)
else:
f.append(0.0)
if values is not None:
# Feature 37
f.append(
min(
values[i] - np.floor(values[i]),
np.ceil(values[i]) - values[i],
)
)
if obj_sa_up is not None:
assert obj_sa_down is not None
assert obj_coeffs is not None
# Convert inf into large finite numbers
sd = max(-1e20, obj_sa_down[i])
su = min(1e20, obj_sa_up[i])
obj = obj_coeffs[i]
# Features 44 and 46
f.append(np.sign(obj_sa_up[i]))
f.append(np.sign(obj_sa_down[i]))
# Feature 47
csign = np.sign(obj)
if csign != 0 and ((obj - sd) / csign) > 0.001:
f.append(log((obj - sd) / csign))
else:
f.append(0.0)
# Feature 48
if csign != 0 and ((su - obj) / csign) > 0.001:
f.append(log((su - obj) / csign))
else:
f.append(0.0)
for v in f:
assert isfinite(v), f"non-finite elements detected: {f}"
features.variables.alvarez_2017.append(f)
def _clip(v: List[float]) -> None:
for (i, vi) in enumerate(v):
if not isfinite(vi):
v[i] = max(min(vi, 1e20), -1e20)

@ -0,0 +1,3 @@
# 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.

@ -0,0 +1,432 @@
# 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 math import log, isfinite
from typing import TYPE_CHECKING, List, Tuple, Optional
import numpy as np
from scipy.sparse import coo_matrix
from miplearn.features.sample import Sample
from miplearn.solvers.internal import LPSolveStats
if TYPE_CHECKING:
from miplearn.solvers.internal import InternalSolver
from miplearn.instance.base import Instance
# noinspection PyPep8Naming
class FeaturesExtractor:
def __init__(
self,
with_sa: bool = True,
with_lhs: bool = True,
) -> None:
self.with_sa = with_sa
self.with_lhs = with_lhs
self.var_features_user: Optional[np.ndarray] = None
def extract_after_load_features(
self,
instance: "Instance",
solver: "InternalSolver",
sample: Sample,
) -> None:
variables = solver.get_variables(with_static=True)
constraints = solver.get_constraints(with_static=True, with_lhs=self.with_lhs)
assert constraints.names is not None
sample.put_array("static_var_lower_bounds", variables.lower_bounds)
sample.put_array("static_var_names", variables.names)
sample.put_array("static_var_obj_coeffs", variables.obj_coeffs)
sample.put_array("static_var_types", variables.types)
sample.put_array("static_var_upper_bounds", variables.upper_bounds)
sample.put_array("static_constr_names", constraints.names)
sample.put_sparse("static_constr_lhs", constraints.lhs)
sample.put_array("static_constr_rhs", constraints.rhs)
sample.put_array("static_constr_senses", constraints.senses)
# Instance features
self._extract_user_features_instance(instance, sample)
# Constraint features
(
constr_features,
constr_categories,
constr_lazy,
) = FeaturesExtractor._extract_user_features_constrs(
instance,
constraints.names,
)
sample.put_array("static_constr_features", constr_features)
sample.put_array("static_constr_categories", constr_categories)
sample.put_array("static_constr_lazy", constr_lazy)
sample.put_scalar("static_constr_lazy_count", int(constr_lazy.sum()))
# Variable features
(
vars_features_user,
var_categories,
) = self._extract_user_features_vars(instance, sample)
self.var_features_user = vars_features_user
sample.put_array("static_var_categories", var_categories)
assert variables.lower_bounds is not None
assert variables.obj_coeffs is not None
assert variables.upper_bounds is not None
sample.put_array(
"static_var_features",
np.hstack(
[
vars_features_user,
self._compute_AlvLouWeh2017(
A=constraints.lhs,
b=constraints.rhs,
c=variables.obj_coeffs,
),
]
),
)
def extract_after_lp_features(
self,
solver: "InternalSolver",
sample: Sample,
lp_stats: LPSolveStats,
) -> None:
for (k, v) in lp_stats.__dict__.items():
sample.put_scalar(k, v)
variables = solver.get_variables(with_static=False, with_sa=self.with_sa)
constraints = solver.get_constraints(with_static=False, with_sa=self.with_sa)
sample.put_array("lp_var_basis_status", variables.basis_status)
sample.put_array("lp_var_reduced_costs", variables.reduced_costs)
sample.put_array("lp_var_sa_lb_down", variables.sa_lb_down)
sample.put_array("lp_var_sa_lb_up", variables.sa_lb_up)
sample.put_array("lp_var_sa_obj_down", variables.sa_obj_down)
sample.put_array("lp_var_sa_obj_up", variables.sa_obj_up)
sample.put_array("lp_var_sa_ub_down", variables.sa_ub_down)
sample.put_array("lp_var_sa_ub_up", variables.sa_ub_up)
sample.put_array("lp_var_values", variables.values)
sample.put_array("lp_constr_basis_status", constraints.basis_status)
sample.put_array("lp_constr_dual_values", constraints.dual_values)
sample.put_array("lp_constr_sa_rhs_down", constraints.sa_rhs_down)
sample.put_array("lp_constr_sa_rhs_up", constraints.sa_rhs_up)
sample.put_array("lp_constr_slacks", constraints.slacks)
# Variable features
lp_var_features_list = []
for f in [
self.var_features_user,
self._compute_AlvLouWeh2017(
A=sample.get_sparse("static_constr_lhs"),
b=sample.get_array("static_constr_rhs"),
c=sample.get_array("static_var_obj_coeffs"),
c_sa_up=variables.sa_obj_up,
c_sa_down=variables.sa_obj_down,
values=variables.values,
),
]:
if f is not None:
lp_var_features_list.append(f)
for f in [
variables.reduced_costs,
variables.sa_lb_down,
variables.sa_lb_up,
variables.sa_obj_down,
variables.sa_obj_up,
variables.sa_ub_down,
variables.sa_ub_up,
variables.values,
]:
if f is not None:
lp_var_features_list.append(f.reshape(-1, 1))
lp_var_features = np.hstack(lp_var_features_list)
_fix_infinity(lp_var_features)
sample.put_array("lp_var_features", lp_var_features)
# Constraint features
lp_constr_features_list = []
for f in [sample.get_array("static_constr_features")]:
if f is not None:
lp_constr_features_list.append(f)
for f in [
sample.get_array("lp_constr_dual_values"),
sample.get_array("lp_constr_sa_rhs_down"),
sample.get_array("lp_constr_sa_rhs_up"),
sample.get_array("lp_constr_slacks"),
]:
if f is not None:
lp_constr_features_list.append(f.reshape(-1, 1))
lp_constr_features = np.hstack(lp_constr_features_list)
_fix_infinity(lp_constr_features)
sample.put_array("lp_constr_features", lp_constr_features)
# Build lp_instance_features
static_instance_features = sample.get_array("static_instance_features")
assert static_instance_features is not None
assert lp_stats.lp_value is not None
assert lp_stats.lp_wallclock_time is not None
sample.put_array(
"lp_instance_features",
np.hstack(
[
static_instance_features,
lp_stats.lp_value,
lp_stats.lp_wallclock_time,
]
),
)
def extract_after_mip_features(
self,
solver: "InternalSolver",
sample: Sample,
) -> None:
variables = solver.get_variables(with_static=False, with_sa=False)
constraints = solver.get_constraints(with_static=False, with_sa=False)
sample.put_array("mip_var_values", variables.values)
sample.put_array("mip_constr_slacks", constraints.slacks)
# noinspection DuplicatedCode
def _extract_user_features_vars(
self,
instance: "Instance",
sample: Sample,
) -> Tuple[np.ndarray, np.ndarray]:
# Query variable names
var_names = sample.get_array("static_var_names")
assert var_names is not None
# Query variable features
var_features = instance.get_variable_features(var_names)
assert isinstance(var_features, np.ndarray), (
f"Variable features must be a numpy array. "
f"Found {var_features.__class__} instead."
)
assert len(var_features.shape) == 2, (
f"Variable features must be 2-dimensional array. "
f"Found array with shape {var_features.shape} instead."
)
assert var_features.shape[0] == len(var_names), (
f"Variable features must have exactly {len(var_names)} rows. "
f"Found {var_features.shape[0]} rows instead."
)
assert var_features.dtype.kind in ["f"], (
f"Variable features must be floating point numbers. "
f"Found {var_features.dtype} instead."
)
# Query variable categories
var_categories = instance.get_variable_categories(var_names)
assert isinstance(var_categories, np.ndarray), (
f"Variable categories must be a numpy array. "
f"Found {var_categories.__class__} instead."
)
assert len(var_categories.shape) == 1, (
f"Variable categories must be a vector. "
f"Found array with shape {var_categories.shape} instead."
)
assert len(var_categories) == len(var_names), (
f"Variable categories must have exactly {len(var_names)} elements. "
f"Found {var_categories.shape[0]} elements instead."
)
assert var_categories.dtype.kind == "S", (
f"Variable categories must be a numpy array with dtype='S'. "
f"Found {var_categories.dtype} instead."
)
return var_features, var_categories
# noinspection DuplicatedCode
@classmethod
def _extract_user_features_constrs(
cls,
instance: "Instance",
constr_names: np.ndarray,
) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
# Query constraint features
constr_features = instance.get_constraint_features(constr_names)
assert isinstance(constr_features, np.ndarray), (
f"get_constraint_features must return a numpy array. "
f"Found {constr_features.__class__} instead."
)
assert len(constr_features.shape) == 2, (
f"get_constraint_features must return a 2-dimensional array. "
f"Found array with shape {constr_features.shape} instead."
)
assert constr_features.shape[0] == len(constr_names), (
f"get_constraint_features must return an array with {len(constr_names)} "
f"rows. Found {constr_features.shape[0]} rows instead."
)
assert constr_features.dtype.kind in ["f"], (
f"get_constraint_features must return floating point numbers. "
f"Found {constr_features.dtype} instead."
)
# Query constraint categories
constr_categories = instance.get_constraint_categories(constr_names)
assert isinstance(constr_categories, np.ndarray), (
f"get_constraint_categories must return a numpy array. "
f"Found {constr_categories.__class__} instead."
)
assert len(constr_categories.shape) == 1, (
f"get_constraint_categories must return a vector. "
f"Found array with shape {constr_categories.shape} instead."
)
assert len(constr_categories) == len(constr_names), (
f"get_constraint_categories must return a vector with {len(constr_names)} "
f"elements. Found {constr_categories.shape[0]} elements instead."
)
assert constr_categories.dtype.kind == "S", (
f"get_constraint_categories must return a numpy array with dtype='S'. "
f"Found {constr_categories.dtype} instead."
)
# Query constraint lazy attribute
constr_lazy = instance.are_constraints_lazy(constr_names)
assert isinstance(constr_lazy, np.ndarray), (
f"are_constraints_lazy must return a numpy array. "
f"Found {constr_lazy.__class__} instead."
)
assert len(constr_lazy.shape) == 1, (
f"are_constraints_lazy must return a vector. "
f"Found array with shape {constr_lazy.shape} instead."
)
assert constr_lazy.shape[0] == len(constr_names), (
f"are_constraints_lazy must return a vector with {len(constr_names)} "
f"elements. Found {constr_lazy.shape[0]} elements instead."
)
assert constr_lazy.dtype.kind == "b", (
f"are_constraints_lazy must return a boolean array. "
f"Found {constr_lazy.dtype} instead."
)
return constr_features, constr_categories, constr_lazy
def _extract_user_features_instance(
self,
instance: "Instance",
sample: Sample,
) -> None:
features = instance.get_instance_features()
assert isinstance(features, np.ndarray), (
f"Instance features must be a numpy array. "
f"Found {features.__class__} instead."
)
assert len(features.shape) == 1, (
f"Instance features must be a vector. "
f"Found array with shape {features.shape} instead."
)
assert features.dtype.kind in [
"f"
], f"Instance features have unsupported {features.dtype}"
sample.put_array("static_instance_features", features)
@classmethod
def _compute_AlvLouWeh2017(
cls,
A: Optional[coo_matrix] = None,
b: Optional[np.ndarray] = None,
c: Optional[np.ndarray] = None,
c_sa_down: Optional[np.ndarray] = None,
c_sa_up: Optional[np.ndarray] = None,
values: Optional[np.ndarray] = None,
) -> np.ndarray:
"""
Computes static variable features described in:
Alvarez, A. M., Louveaux, Q., & Wehenkel, L. (2017). A machine learning-based
approximation of strong branching. INFORMS Journal on Computing, 29(1),
185-195.
"""
assert b is not None
assert c is not None
nvars = len(c)
c_pos_sum = c[c > 0].sum()
c_neg_sum = -c[c < 0].sum()
curr = 0
max_n_features = 30
features = np.zeros((nvars, max_n_features))
def push(v: np.ndarray) -> None:
nonlocal curr
features[:, curr] = v
curr += 1
with np.errstate(divide="ignore", invalid="ignore"):
# Feature 1
push(np.sign(c))
# Feature 2
push(np.abs(c) / c_pos_sum)
# Feature 3
push(np.abs(c) / c_neg_sum)
if A is not None:
assert A.shape[1] == nvars
assert A.shape[0] == len(b)
M1 = A.T.multiply(1.0 / np.abs(b)).T.tocsr()
M1_pos = M1[b > 0, :]
if M1_pos.shape[0] > 0:
M1_pos_max = M1_pos.max(axis=0).todense()
M1_pos_min = M1_pos.min(axis=0).todense()
else:
M1_pos_max = np.zeros(nvars)
M1_pos_min = np.zeros(nvars)
M1_neg = M1[b < 0, :]
if M1_neg.shape[0] > 0:
M1_neg_max = M1_neg.max(axis=0).todense()
M1_neg_min = M1_neg.min(axis=0).todense()
else:
M1_neg_max = np.zeros(nvars)
M1_neg_min = np.zeros(nvars)
# Features 4-11
push(np.sign(M1_pos_min))
push(np.sign(M1_pos_max))
push(np.abs(M1_pos_min))
push(np.abs(M1_pos_max))
push(np.sign(M1_neg_min))
push(np.sign(M1_neg_max))
push(np.abs(M1_neg_min))
push(np.abs(M1_neg_max))
# Feature 37
if values is not None:
push(
np.minimum(
values - np.floor(values),
np.ceil(values) - values,
)
)
# Feature 44
if c_sa_up is not None:
push(np.sign(c_sa_up))
# Feature 46
if c_sa_down is not None:
push(np.sign(c_sa_down))
# Feature 47
if c_sa_down is not None:
push(np.log(c - c_sa_down / np.sign(c)))
# Feature 48
if c_sa_up is not None:
push(np.log(c - c_sa_up / np.sign(c)))
features = features[:, 0:curr]
_fix_infinity(features)
return features
def _fix_infinity(m: Optional[np.ndarray]) -> None:
if m is None:
return
masked = np.ma.masked_invalid(m)
max_values = np.max(masked, axis=0)
min_values = np.min(masked, axis=0)
m[:] = np.maximum(np.minimum(m, max_values), min_values)
m[~np.isfinite(m)] = 0.0

@ -0,0 +1,226 @@
# 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 warnings
from abc import ABC, abstractmethod
from copy import deepcopy
from typing import Dict, Optional, Any, Union, List, Tuple, cast, Set
from scipy.sparse import coo_matrix
import h5py
import numpy as np
from h5py import Dataset
from overrides import overrides
Bytes = Union[bytes, bytearray]
Scalar = Union[None, bool, str, int, float]
Vector = Union[
None,
List[bool],
List[str],
List[int],
List[float],
List[Optional[str]],
np.ndarray,
]
VectorList = Union[
List[List[bool]],
List[List[str]],
List[List[int]],
List[List[float]],
List[Optional[List[bool]]],
List[Optional[List[str]]],
List[Optional[List[int]]],
List[Optional[List[float]]],
]
class Sample(ABC):
"""Abstract dictionary-like class that stores training data."""
@abstractmethod
def get_scalar(self, key: str) -> Optional[Any]:
pass
@abstractmethod
def put_scalar(self, key: str, value: Scalar) -> None:
pass
@abstractmethod
def put_array(self, key: str, value: Optional[np.ndarray]) -> None:
pass
@abstractmethod
def get_array(self, key: str) -> Optional[np.ndarray]:
pass
@abstractmethod
def put_sparse(self, key: str, value: coo_matrix) -> None:
pass
@abstractmethod
def get_sparse(self, key: str) -> Optional[coo_matrix]:
pass
def _assert_is_scalar(self, value: Any) -> None:
if value is None:
return
if isinstance(value, (str, bool, int, float, bytes, np.bytes_)):
return
assert False, f"scalar expected; found instead: {value} ({value.__class__})"
def _assert_is_array(self, value: np.ndarray) -> None:
assert isinstance(
value, np.ndarray
), f"np.ndarray expected; found instead: {value.__class__}"
assert value.dtype.kind in "biufS", f"Unsupported dtype: {value.dtype}"
def _assert_is_sparse(self, value: Any) -> None:
assert isinstance(
value, coo_matrix
), f"coo_matrix expected; found: {value.__class__}"
self._assert_is_array(value.data)
class MemorySample(Sample):
"""Dictionary-like class that stores training data in-memory."""
def __init__(
self,
data: Optional[Dict[str, Any]] = None,
) -> None:
if data is None:
data = {}
self._data: Dict[str, Any] = data
@overrides
def get_scalar(self, key: str) -> Optional[Any]:
return self._get(key)
@overrides
def put_scalar(self, key: str, value: Scalar) -> None:
if value is None:
return
self._assert_is_scalar(value)
self._put(key, value)
def _get(self, key: str) -> Optional[Any]:
if key in self._data:
return self._data[key]
else:
return None
def _put(self, key: str, value: Any) -> None:
self._data[key] = value
@overrides
def put_array(self, key: str, value: Optional[np.ndarray]) -> None:
if value is None:
return
self._assert_is_array(value)
self._put(key, value)
@overrides
def get_array(self, key: str) -> Optional[np.ndarray]:
return cast(Optional[np.ndarray], self._get(key))
@overrides
def put_sparse(self, key: str, value: coo_matrix) -> None:
if value is None:
return
self._assert_is_sparse(value)
self._put(key, value)
@overrides
def get_sparse(self, key: str) -> Optional[coo_matrix]:
return cast(Optional[coo_matrix], self._get(key))
class Hdf5Sample(Sample):
"""
Dictionary-like class that stores training data in an HDF5 file.
Unlike MemorySample, this class only loads to memory the parts of the data set that
are actually accessed, and therefore it is more scalable.
"""
def __init__(
self,
filename: str,
mode: str = "r+",
) -> None:
self.file = h5py.File(filename, mode, libver="latest")
@overrides
def get_scalar(self, key: str) -> Optional[Any]:
if key not in self.file:
return None
ds = self.file[key]
assert (
len(ds.shape) == 0
), f"0-dimensional array expected; found shape {ds.shape}"
if h5py.check_string_dtype(ds.dtype):
return ds.asstr()[()]
else:
return ds[()].tolist()
@overrides
def put_scalar(self, key: str, value: Any) -> None:
if value is None:
return
self._assert_is_scalar(value)
if key in self.file:
del self.file[key]
self.file.create_dataset(key, data=value)
@overrides
def put_array(self, key: str, value: Optional[np.ndarray]) -> None:
if value is None:
return
self._assert_is_array(value)
if value.dtype.kind == "f":
value = value.astype("float32")
if key in self.file:
del self.file[key]
return self.file.create_dataset(key, data=value, compression="gzip")
@overrides
def get_array(self, key: str) -> Optional[np.ndarray]:
if key not in self.file:
return None
return self.file[key][:]
@overrides
def put_sparse(self, key: str, value: coo_matrix) -> None:
if value is None:
return
self._assert_is_sparse(value)
self.put_array(f"{key}_row", value.row)
self.put_array(f"{key}_col", value.col)
self.put_array(f"{key}_data", value.data)
@overrides
def get_sparse(self, key: str) -> Optional[coo_matrix]:
row = self.get_array(f"{key}_row")
if row is None:
return None
col = self.get_array(f"{key}_col")
data = self.get_array(f"{key}_data")
assert col is not None
assert data is not None
return coo_matrix((data, (row, col)))
def get_bytes(self, key: str) -> Optional[Bytes]:
if key not in self.file:
return None
ds = self.file[key]
assert (
len(ds.shape) == 1
), f"1-dimensional array expected; found shape {ds.shape}"
return ds[()].tobytes()
def put_bytes(self, key: str, value: Bytes) -> None:
assert isinstance(
value, (bytes, bytearray)
), f"bytes expected; found: {value.__class__}" # type: ignore
self.put_array(key, np.frombuffer(value, dtype="uint8"))

@ -4,9 +4,12 @@
import logging
from abc import ABC, abstractmethod
from typing import Any, List, Hashable, TYPE_CHECKING, Dict
from typing import Any, List, TYPE_CHECKING, Dict
from miplearn.features import Sample
import numpy as np
from miplearn.features.sample import Sample, MemorySample
from miplearn.types import ConstraintName, ConstraintCategory
logger = logging.getLogger(__name__)
@ -37,7 +40,7 @@ class Instance(ABC):
"""
pass
def get_instance_features(self) -> List[float]:
def get_instance_features(self) -> np.ndarray:
"""
Returns a 1-dimensional array of (numerical) features describing the
entire instance.
@ -59,9 +62,9 @@ class Instance(ABC):
By default, returns [0.0].
"""
return [0.0]
return np.zeros(1)
def get_variable_features(self) -> Dict[str, List[float]]:
def get_variable_features(self, names: np.ndarray) -> np.ndarray:
"""
Returns dictionary mapping the name of each variable to a (1-dimensional) list
of numerical features describing a particular decision variable.
@ -79,11 +82,11 @@ class Instance(ABC):
If features are not provided for a given variable, MIPLearn will use a
default set of features.
By default, returns {}.
By default, returns [[0.0], ..., [0.0]].
"""
return {}
return np.zeros((len(names), 1))
def get_variable_categories(self) -> Dict[str, Hashable]:
def get_variable_categories(self, names: np.ndarray) -> np.ndarray:
"""
Returns a dictionary mapping the name of each variable to its category.
@ -91,31 +94,27 @@ class Instance(ABC):
internal ML model to predict the values of both variables. If a variable is not
listed in the dictionary, ML models will ignore the variable.
A category can be any hashable type, such as strings, numbers or tuples.
By default, returns {}.
By default, returns `names`.
"""
return {}
def get_constraint_features(self) -> Dict[str, List[float]]:
return {}
return names
def get_constraint_categories(self) -> Dict[str, Hashable]:
return {}
def get_constraint_features(self, names: np.ndarray) -> np.ndarray:
return np.zeros((len(names), 1))
def has_static_lazy_constraints(self) -> bool:
return False
def get_constraint_categories(self, names: np.ndarray) -> np.ndarray:
return names
def has_dynamic_lazy_constraints(self) -> bool:
return False
def is_constraint_lazy(self, cid: str) -> bool:
return False
def are_constraints_lazy(self, names: np.ndarray) -> np.ndarray:
return np.zeros(len(names), dtype=bool)
def find_violated_lazy_constraints(
self,
solver: "InternalSolver",
model: Any,
) -> List[Hashable]:
) -> List[ConstraintName]:
"""
Returns lazy constraint violations found for the current solution.
@ -125,10 +124,10 @@ class Instance(ABC):
resolve the problem. The process repeats until no further lazy constraint
violations are found.
Each "violation" is simply a string, a tuple or any other hashable type 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 frozen set containing the cities in the subtour.
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.
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.
@ -141,7 +140,7 @@ class Instance(ABC):
self,
solver: "InternalSolver",
model: Any,
violation: Hashable,
violation: ConstraintName,
) -> None:
"""
Adds constraints to the model to ensure that the given violation is fixed.
@ -167,14 +166,14 @@ class Instance(ABC):
def has_user_cuts(self) -> bool:
return False
def find_violated_user_cuts(self, model: Any) -> List[Hashable]:
def find_violated_user_cuts(self, model: Any) -> List[ConstraintName]:
return []
def enforce_user_cut(
self,
solver: "InternalSolver",
model: Any,
violation: Hashable,
violation: ConstraintName,
) -> Any:
return None
@ -193,5 +192,7 @@ class Instance(ABC):
def get_samples(self) -> List[Sample]:
return self._samples
def push_sample(self, sample: Sample) -> None:
def create_sample(self) -> Sample:
sample = MemorySample()
self._samples.append(sample)
return sample

@ -0,0 +1,131 @@
# 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 gc
import os
from typing import Any, Optional, List, Dict, TYPE_CHECKING
import pickle
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
if TYPE_CHECKING:
from miplearn.solvers.learning import InternalSolver
class FileInstance(Instance):
def __init__(self, filename: str) -> None:
super().__init__()
assert os.path.exists(filename), f"File not found: {filename}"
self.h5 = Hdf5Sample(filename)
self.instance: Optional[Instance] = None
# Delegation
# -------------------------------------------------------------------------
@overrides
def to_model(self) -> Any:
assert self.instance is not None
return self.instance.to_model()
@overrides
def get_instance_features(self) -> np.ndarray:
assert self.instance is not None
return self.instance.get_instance_features()
@overrides
def get_variable_features(self, names: np.ndarray) -> np.ndarray:
assert self.instance is not None
return self.instance.get_variable_features(names)
@overrides
def get_variable_categories(self, names: np.ndarray) -> np.ndarray:
assert self.instance is not None
return self.instance.get_variable_categories(names)
@overrides
def get_constraint_features(self, names: np.ndarray) -> np.ndarray:
assert self.instance is not None
return self.instance.get_constraint_features(names)
@overrides
def get_constraint_categories(self, names: np.ndarray) -> np.ndarray:
assert self.instance is not None
return self.instance.get_constraint_categories(names)
@overrides
def has_dynamic_lazy_constraints(self) -> bool:
assert self.instance is not None
return self.instance.has_dynamic_lazy_constraints()
@overrides
def are_constraints_lazy(self, names: np.ndarray) -> np.ndarray:
assert self.instance is not None
return self.instance.are_constraints_lazy(names)
@overrides
def find_violated_lazy_constraints(
self,
solver: "InternalSolver",
model: Any,
) -> List[ConstraintName]:
assert self.instance is not None
return self.instance.find_violated_lazy_constraints(solver, model)
@overrides
def enforce_lazy_constraint(
self,
solver: "InternalSolver",
model: Any,
violation: ConstraintName,
) -> None:
assert self.instance is not None
self.instance.enforce_lazy_constraint(solver, model, violation)
@overrides
def find_violated_user_cuts(self, model: Any) -> List[ConstraintName]:
assert self.instance is not None
return self.instance.find_violated_user_cuts(model)
@overrides
def enforce_user_cut(
self,
solver: "InternalSolver",
model: Any,
violation: ConstraintName,
) -> None:
assert self.instance is not None
self.instance.enforce_user_cut(solver, model, violation)
# Input & Output
# -------------------------------------------------------------------------
@overrides
def free(self) -> None:
self.instance = None
gc.collect()
@overrides
def load(self) -> None:
if self.instance is not None:
return
pkl = self.h5.get_bytes("pickled")
assert pkl is not None
self.instance = pickle.loads(pkl)
assert isinstance(self.instance, Instance)
@classmethod
def save(cls, instance: Instance, filename: str) -> None:
h5 = Hdf5Sample(filename, mode="w")
instance_pkl = pickle.dumps(instance)
h5.put_bytes("pickled", instance_pkl)
@overrides
def create_sample(self) -> Sample:
return self.h5
@overrides
def get_samples(self) -> List[Sample]:
return [self.h5]

@ -6,12 +6,14 @@ import gc
import gzip
import os
import pickle
from typing import Optional, Any, List, Hashable, cast, IO, TYPE_CHECKING, Dict
from typing import Optional, Any, List, cast, IO, TYPE_CHECKING, Dict
import numpy as np
from overrides import overrides
from miplearn.features import Sample
from miplearn.features.sample import Sample
from miplearn.instance.base import Instance
from miplearn.types import ConstraintName, ConstraintCategory
if TYPE_CHECKING:
from miplearn.solvers.learning import InternalSolver
@ -42,34 +44,29 @@ class PickleGzInstance(Instance):
return self.instance.to_model()
@overrides
def get_instance_features(self) -> List[float]:
def get_instance_features(self) -> np.ndarray:
assert self.instance is not None
return self.instance.get_instance_features()
@overrides
def get_variable_features(self) -> Dict[str, List[float]]:
def get_variable_features(self, names: np.ndarray) -> np.ndarray:
assert self.instance is not None
return self.instance.get_variable_features()
return self.instance.get_variable_features(names)
@overrides
def get_variable_categories(self) -> Dict[str, Hashable]:
def get_variable_categories(self, names: np.ndarray) -> np.ndarray:
assert self.instance is not None
return self.instance.get_variable_categories()
return self.instance.get_variable_categories(names)
@overrides
def get_constraint_features(self) -> Dict[str, List[float]]:
def get_constraint_features(self, names: np.ndarray) -> np.ndarray:
assert self.instance is not None
return self.instance.get_constraint_features()
return self.instance.get_constraint_features(names)
@overrides
def get_constraint_categories(self) -> Dict[str, Hashable]:
def get_constraint_categories(self, names: np.ndarray) -> np.ndarray:
assert self.instance is not None
return self.instance.get_constraint_categories()
@overrides
def has_static_lazy_constraints(self) -> bool:
assert self.instance is not None
return self.instance.has_static_lazy_constraints()
return self.instance.get_constraint_categories(names)
@overrides
def has_dynamic_lazy_constraints(self) -> bool:
@ -77,16 +74,16 @@ class PickleGzInstance(Instance):
return self.instance.has_dynamic_lazy_constraints()
@overrides
def is_constraint_lazy(self, cid: str) -> bool:
def are_constraints_lazy(self, names: np.ndarray) -> np.ndarray:
assert self.instance is not None
return self.instance.is_constraint_lazy(cid)
return self.instance.are_constraints_lazy(names)
@overrides
def find_violated_lazy_constraints(
self,
solver: "InternalSolver",
model: Any,
) -> List[Hashable]:
) -> List[ConstraintName]:
assert self.instance is not None
return self.instance.find_violated_lazy_constraints(solver, model)
@ -95,13 +92,13 @@ class PickleGzInstance(Instance):
self,
solver: "InternalSolver",
model: Any,
violation: Hashable,
violation: ConstraintName,
) -> None:
assert self.instance is not None
self.instance.enforce_lazy_constraint(solver, model, violation)
@overrides
def find_violated_user_cuts(self, model: Any) -> List[Hashable]:
def find_violated_user_cuts(self, model: Any) -> List[ConstraintName]:
assert self.instance is not None
return self.instance.find_violated_user_cuts(model)
@ -110,7 +107,7 @@ class PickleGzInstance(Instance):
self,
solver: "InternalSolver",
model: Any,
violation: Hashable,
violation: ConstraintName,
) -> None:
assert self.instance is not None
self.instance.enforce_user_cut(solver, model, violation)
@ -137,9 +134,9 @@ class PickleGzInstance(Instance):
return self.instance.get_samples()
@overrides
def push_sample(self, sample: Sample) -> None:
def create_sample(self) -> Sample:
assert self.instance is not None
self.instance.push_sample(sample)
return self.instance.create_sample()
def write_pickle_gz(obj: Any, filename: str) -> None:

@ -1,7 +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.
from typing import List, Dict, Optional, Hashable, Any
from typing import List, Dict, Optional
import numpy as np
import pyomo.environ as pe
@ -10,7 +11,6 @@ from scipy.stats import uniform, randint, rv_discrete
from scipy.stats.distributions import rv_frozen
from miplearn.instance.base import Instance
from miplearn.types import VariableName, Category
class ChallengeA:
@ -94,15 +94,17 @@ class MultiKnapsackInstance(Instance):
return model
@overrides
def get_instance_features(self) -> List[float]:
return [float(np.mean(self.prices))] + list(self.capacities)
def get_instance_features(self) -> np.ndarray:
return np.array([float(np.mean(self.prices))] + list(self.capacities))
@overrides
def get_variable_features(self) -> Dict[str, List[float]]:
return {
f"x[{i}]": [self.prices[i] + list(self.weights[:, i])]
for i in range(self.n)
}
def get_variable_features(self, names: np.ndarray) -> np.ndarray:
features = []
for i in range(len(self.weights)):
f = [self.prices[i]]
f.extend(self.weights[:, i])
features.append(f)
return np.array(features)
# noinspection PyPep8Naming

@ -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, Dict, Hashable
from typing import List, Dict
import networkx as nx
import numpy as np
@ -12,7 +12,6 @@ from scipy.stats import uniform, randint
from scipy.stats.distributions import rv_frozen
from miplearn.instance.base import Instance
from miplearn.types import VariableName, Category
class ChallengeA:
@ -67,9 +66,11 @@ class MaxWeightStableSetInstance(Instance):
return model
@overrides
def get_variable_features(self) -> Dict[str, List[float]]:
features = {}
for v1 in self.nodes:
def get_variable_features(self, names: np.ndarray) -> np.ndarray:
features = []
assert len(names) == len(self.nodes)
for i, v1 in enumerate(self.nodes):
assert names[i] == f"x[{v1}]".encode()
neighbor_weights = [0.0] * 15
neighbor_degrees = [100.0] * 15
for v2 in self.graph.neighbors(v1):
@ -81,12 +82,12 @@ class MaxWeightStableSetInstance(Instance):
f += neighbor_weights[:5]
f += neighbor_degrees[:5]
f += [self.graph.degree(v1)]
features[f"x[{v1}]"] = f
return features
features.append(f)
return np.array(features)
@overrides
def get_variable_categories(self) -> Dict[str, Hashable]:
return {f"x[{v}]": "default" for v in self.nodes}
def get_variable_categories(self, names: np.ndarray) -> np.ndarray:
return np.array(["default" for _ in names], dtype="S")
class MaxWeightStableSetGenerator:

@ -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, Hashable, Dict
from typing import List, Tuple, FrozenSet, Any, Optional, Dict
import networkx as nx
import numpy as np
@ -11,10 +11,10 @@ from scipy.spatial.distance import pdist, squareform
from scipy.stats import uniform, randint
from scipy.stats.distributions import rv_frozen
from miplearn.instance.base import Instance
from miplearn.solvers.learning import InternalSolver
from miplearn.solvers.pyomo.base import BasePyomoSolver
from miplearn.instance.base import Instance
from miplearn.types import VariableName, Category
from miplearn.types import ConstraintName
class ChallengeA:
@ -81,24 +81,19 @@ class TravelingSalesmanInstance(Instance):
)
return model
@overrides
def get_variable_categories(self) -> Dict[str, Hashable]:
return {f"x[{e}]": f"x[{e}]" for e in self.edges}
@overrides
def find_violated_lazy_constraints(
self,
solver: InternalSolver,
model: Any,
) -> List[FrozenSet]:
) -> List[ConstraintName]:
selected_edges = [e for e in self.edges if model.x[e].value > 0.5]
graph = nx.Graph()
graph.add_edges_from(selected_edges)
components = [frozenset(c) for c in list(nx.connected_components(graph))]
violations = []
for c in components:
for c in list(nx.connected_components(graph)):
if len(c) < self.n_cities:
violations += [c]
violations.append(",".join(map(str, c)).encode())
return violations
@overrides
@ -106,9 +101,10 @@ class TravelingSalesmanInstance(Instance):
self,
solver: InternalSolver,
model: Any,
component: FrozenSet,
violation: ConstraintName,
) -> None:
assert isinstance(solver, BasePyomoSolver)
component = [int(v) for v in violation.decode().split(",")]
cut_edges = [
e
for e in self.edges

@ -4,7 +4,7 @@
import logging
import sys
from typing import Any, List, TextIO, cast
from typing import Any, List, TextIO, cast, TypeVar, Optional, Sized
logger = logging.getLogger(__name__)
@ -38,7 +38,10 @@ class _RedirectOutput:
sys.stderr = self._original_stderr
def _none_if_empty(obj: Any) -> Any:
T = TypeVar("T", bound=Sized)
def _none_if_empty(obj: T) -> Optional[T]:
if len(obj) == 0:
return None
else:

@ -6,11 +6,12 @@ import re
import sys
from io import StringIO
from random import randint
from typing import List, Any, Dict, Optional, Hashable, Tuple, TYPE_CHECKING
from typing import List, Any, Dict, Optional, TYPE_CHECKING
import numpy as np
from overrides import overrides
from scipy.sparse import coo_matrix, lil_matrix
from miplearn.features import VariableFeatures, ConstraintFeatures
from miplearn.instance.base import Instance
from miplearn.solvers import _RedirectOutput
from miplearn.solvers.internal import (
@ -19,6 +20,8 @@ from miplearn.solvers.internal import (
IterationCallback,
LazyCallback,
MIPSolveStats,
Variables,
Constraints,
)
from miplearn.solvers.pyomo.base import PyomoTestInstanceKnapsack
from miplearn.types import (
@ -71,16 +74,16 @@ class GurobiSolver(InternalSolver):
self._has_lp_solution = False
self._has_mip_solution = False
self._varname_to_var: Dict[str, "gurobipy.Var"] = {}
self._varname_to_var: Dict[bytes, "gurobipy.Var"] = {}
self._cname_to_constr: Dict[str, "gurobipy.Constr"] = {}
self._gp_vars: List["gurobipy.Var"] = []
self._gp_constrs: List["gurobipy.Constr"] = []
self._var_names: List[str] = []
self._var_names: np.ndarray = np.empty(0)
self._constr_names: List[str] = []
self._var_types: List[str] = []
self._var_lbs: List[float] = []
self._var_ubs: List[float] = []
self._var_obj_coeffs: List[float] = []
self._var_types: np.ndarray = np.empty(0)
self._var_lbs: np.ndarray = np.empty(0)
self._var_ubs: np.ndarray = np.empty(0)
self._var_obj_coeffs: np.ndarray = np.empty(0)
if self.lazy_cb_frequency == 1:
self.lazy_cb_where = [self.gp.GRB.Callback.MIPSOL]
@ -91,23 +94,27 @@ class GurobiSolver(InternalSolver):
]
@overrides
def add_constraints(self, cf: ConstraintFeatures) -> None:
def add_constraints(self, cf: Constraints) -> None:
assert cf.names is not None
assert cf.senses is not None
assert cf.lhs is not None
assert cf.rhs is not None
assert self.model is not None
lhs = cf.lhs.tocsr()
for i in range(len(cf.names)):
sense = cf.senses[i]
lhs = self.gp.quicksum(
self._varname_to_var[varname] * coeff for (varname, coeff) in cf.lhs[i]
row = lhs[i, :]
row_expr = self.gp.quicksum(
self._gp_vars[row.indices[j]] * row.data[j] for j in range(row.getnnz())
)
if sense == "=":
self.model.addConstr(lhs == cf.rhs[i], name=cf.names[i])
elif sense == "<":
self.model.addConstr(lhs <= cf.rhs[i], name=cf.names[i])
if sense == b"=":
self.model.addConstr(row_expr == cf.rhs[i], name=cf.names[i])
elif sense == b"<":
self.model.addConstr(row_expr <= cf.rhs[i], name=cf.names[i])
elif sense == b">":
self.model.addConstr(row_expr >= cf.rhs[i], name=cf.names[i])
else:
self.model.addConstr(lhs >= cf.rhs[i], name=cf.names[i])
raise Exception(f"Unknown sense: {sense}")
self.model.update()
self._dirty = True
self._has_lp_solution = False
@ -120,7 +127,7 @@ class GurobiSolver(InternalSolver):
@overrides
def are_constraints_satisfied(
self,
cf: ConstraintFeatures,
cf: Constraints,
tol: float = 1e-5,
) -> List[bool]:
assert cf.names is not None
@ -129,18 +136,18 @@ class GurobiSolver(InternalSolver):
assert cf.rhs is not None
assert self.model is not None
result = []
x = np.array(self.model.getAttr("x", self.model.getVars()))
lhs = cf.lhs.tocsr() * x
for i in range(len(cf.names)):
sense = cf.senses[i]
lhs = sum(
self._varname_to_var[varname].x * coeff
for (varname, coeff) in cf.lhs[i]
)
if sense == "<":
result.append(lhs <= cf.rhs[i] + tol)
elif sense == ">":
result.append(lhs >= cf.rhs[i] - tol)
if sense == b"<":
result.append(lhs[i] <= cf.rhs[i] + tol)
elif sense == b">":
result.append(lhs[i] >= cf.rhs[i] - tol)
elif sense == b"<":
result.append(abs(cf.rhs[i] - lhs[i]) <= tol)
else:
result.append(abs(cf.rhs[i] - lhs) <= tol)
raise Exception(f"unknown sense: {sense}")
return result
@overrides
@ -196,7 +203,7 @@ class GurobiSolver(InternalSolver):
with_static: bool = True,
with_sa: bool = True,
with_lhs: bool = True,
) -> ConstraintFeatures:
) -> Constraints:
model = self.model
assert model is not None
assert model.numVars == len(self._gp_vars)
@ -209,39 +216,40 @@ class GurobiSolver(InternalSolver):
raise Exception(f"unknown cbasis: {v}")
gp_constrs = model.getConstrs()
constr_names = model.getAttr("constrName", gp_constrs)
lhs: Optional[List] = None
constr_names = np.array(model.getAttr("constrName", gp_constrs), dtype="S")
lhs: Optional[coo_matrix] = None
rhs, senses, slacks, basis_status = None, None, None, None
dual_value, basis_status, sa_rhs_up, sa_rhs_down = None, None, None, None
if with_static:
rhs = model.getAttr("rhs", gp_constrs)
senses = model.getAttr("sense", gp_constrs)
rhs = np.array(model.getAttr("rhs", gp_constrs), dtype=float)
senses = np.array(model.getAttr("sense", gp_constrs), dtype="S")
if with_lhs:
lhs = [None for _ in gp_constrs]
nrows = len(gp_constrs)
ncols = len(self._var_names)
tmp = lil_matrix((nrows, ncols), dtype=float)
for (i, gp_constr) in enumerate(gp_constrs):
expr = model.getRow(gp_constr)
lhs[i] = [
(self._var_names[expr.getVar(j).index], expr.getCoeff(j))
for j in range(expr.size())
]
for j in range(expr.size()):
tmp[i, expr.getVar(j).index] = expr.getCoeff(j)
lhs = tmp.tocoo()
if self._has_lp_solution:
dual_value = model.getAttr("pi", gp_constrs)
basis_status = list(
map(
_parse_gurobi_cbasis,
model.getAttr("cbasis", gp_constrs),
)
dual_value = np.array(model.getAttr("pi", gp_constrs), dtype=float)
basis_status = np.array(
[_parse_gurobi_cbasis(c) for c in model.getAttr("cbasis", gp_constrs)],
dtype="S",
)
if with_sa:
sa_rhs_up = model.getAttr("saRhsUp", gp_constrs)
sa_rhs_down = model.getAttr("saRhsLow", gp_constrs)
sa_rhs_up = np.array(model.getAttr("saRhsUp", gp_constrs), dtype=float)
sa_rhs_down = np.array(
model.getAttr("saRhsLow", gp_constrs), dtype=float
)
if self._has_lp_solution or self._has_mip_solution:
slacks = model.getAttr("slack", gp_constrs)
slacks = np.array(model.getAttr("slack", gp_constrs), dtype=float)
return ConstraintFeatures(
return Constraints(
basis_status=basis_status,
dual_values=dual_value,
lhs=lhs,
@ -259,11 +267,13 @@ class GurobiSolver(InternalSolver):
if self.cb_where is not None:
if self.cb_where == self.gp.GRB.Callback.MIPNODE:
return {
v.varName: self.model.cbGetNodeRel(v) for v in self.model.getVars()
v.varName.encode(): self.model.cbGetNodeRel(v)
for v in self.model.getVars()
}
elif self.cb_where == self.gp.GRB.Callback.MIPSOL:
return {
v.varName: self.model.cbGetSolution(v) for v in self.model.getVars()
v.varName.encode(): self.model.cbGetSolution(v)
for v in self.model.getVars()
}
else:
raise Exception(
@ -272,7 +282,7 @@ class GurobiSolver(InternalSolver):
)
if self.model.solCount == 0:
return None
return {v.varName: v.x for v in self.model.getVars()}
return {v.varName.encode(): v.x for v in self.model.getVars()}
@overrides
def get_variable_attrs(self) -> List[str]:
@ -300,7 +310,7 @@ class GurobiSolver(InternalSolver):
self,
with_static: bool = True,
with_sa: bool = True,
) -> VariableFeatures:
) -> Variables:
model = self.model
assert model is not None
@ -316,8 +326,9 @@ class GurobiSolver(InternalSolver):
else:
raise Exception(f"unknown vbasis: {basis_status}")
basis_status: Optional[np.ndarray] = None
upper_bounds, lower_bounds, types, values = None, None, None, None
obj_coeffs, reduced_costs, basis_status = None, None, None
obj_coeffs, reduced_costs = None, None
sa_obj_up, sa_ub_up, sa_lb_up = None, None, None
sa_obj_down, sa_ub_down, sa_lb_down = None, None, None
@ -328,26 +339,45 @@ class GurobiSolver(InternalSolver):
obj_coeffs = self._var_obj_coeffs
if self._has_lp_solution:
reduced_costs = model.getAttr("rc", self._gp_vars)
basis_status = list(
map(
_parse_gurobi_vbasis,
model.getAttr("vbasis", self._gp_vars),
)
reduced_costs = np.array(model.getAttr("rc", self._gp_vars), dtype=float)
basis_status = np.array(
[
_parse_gurobi_vbasis(b)
for b in model.getAttr("vbasis", self._gp_vars)
],
dtype="S",
)
if with_sa:
sa_obj_up = model.getAttr("saobjUp", self._gp_vars)
sa_obj_down = model.getAttr("saobjLow", self._gp_vars)
sa_ub_up = model.getAttr("saubUp", self._gp_vars)
sa_ub_down = model.getAttr("saubLow", self._gp_vars)
sa_lb_up = model.getAttr("salbUp", self._gp_vars)
sa_lb_down = model.getAttr("salbLow", self._gp_vars)
sa_obj_up = np.array(
model.getAttr("saobjUp", self._gp_vars),
dtype=float,
)
sa_obj_down = np.array(
model.getAttr("saobjLow", self._gp_vars),
dtype=float,
)
sa_ub_up = np.array(
model.getAttr("saubUp", self._gp_vars),
dtype=float,
)
sa_ub_down = np.array(
model.getAttr("saubLow", self._gp_vars),
dtype=float,
)
sa_lb_up = np.array(
model.getAttr("salbUp", self._gp_vars),
dtype=float,
)
sa_lb_down = np.array(
model.getAttr("salbLow", self._gp_vars),
dtype=float,
)
if model.solCount > 0:
values = model.getAttr("x", self._gp_vars)
values = np.array(model.getAttr("x", self._gp_vars), dtype=float)
return VariableFeatures(
return Variables(
names=self._var_names,
upper_bounds=upper_bounds,
lower_bounds=lower_bounds,
@ -489,7 +519,7 @@ class GurobiSolver(InternalSolver):
self._apply_params(streams)
assert self.model is not None
for (i, var) in enumerate(self._gp_vars):
if self._var_types[i] == "B":
if self._var_types[i] == b"B":
var.vtype = self.gp.GRB.CONTINUOUS
var.lb = 0.0
var.ub = 1.0
@ -497,7 +527,7 @@ class GurobiSolver(InternalSolver):
self.model.optimize()
self._dirty = False
for (i, var) in enumerate(self._gp_vars):
if self._var_types[i] == "B":
if self._var_types[i] == b"B":
var.vtype = self.gp.GRB.BINARY
log = streams[0].getvalue()
self._has_lp_solution = self.model.solCount > 0
@ -562,32 +592,47 @@ class GurobiSolver(InternalSolver):
assert self.model is not None
gp_vars: List["gurobipy.Var"] = self.model.getVars()
gp_constrs: List["gurobipy.Constr"] = self.model.getConstrs()
var_names: List[str] = self.model.getAttr("varName", gp_vars)
var_types: List[str] = self.model.getAttr("vtype", gp_vars)
var_ubs: List[float] = self.model.getAttr("ub", gp_vars)
var_lbs: List[float] = self.model.getAttr("lb", gp_vars)
var_obj_coeffs: List[float] = self.model.getAttr("obj", gp_vars)
var_names: np.ndarray = np.array(
self.model.getAttr("varName", gp_vars),
dtype="S",
)
var_types: np.ndarray = np.array(
self.model.getAttr("vtype", gp_vars),
dtype="S",
)
var_ubs: np.ndarray = np.array(
self.model.getAttr("ub", gp_vars),
dtype=float,
)
var_lbs: np.ndarray = np.array(
self.model.getAttr("lb", gp_vars),
dtype=float,
)
var_obj_coeffs: np.ndarray = np.array(
self.model.getAttr("obj", gp_vars),
dtype=float,
)
constr_names: List[str] = self.model.getAttr("constrName", gp_constrs)
varname_to_var: Dict = {}
varname_to_var: Dict[bytes, "gurobipy.Var"] = {}
cname_to_constr: Dict = {}
for (i, gp_var) in enumerate(gp_vars):
assert var_names[i] not in varname_to_var, (
f"Duplicated variable name detected: {var_names[i]}. "
f"Unique variable names are currently required."
)
if var_types[i] == "I":
if var_types[i] == b"I":
assert var_ubs[i] == 1.0, (
"Only binary and continuous variables are currently supported. "
"Integer variable {var.varName} has upper bound {var.ub}."
f"Integer variable {var_names[i]} has upper bound {var_ubs[i]}."
)
assert var_lbs[i] == 0.0, (
"Only binary and continuous variables are currently supported. "
"Integer variable {var.varName} has lower bound {var.ub}."
f"Integer variable {var_names[i]} has lower bound {var_ubs[i]}."
)
var_types[i] = "B"
assert var_types[i] in ["B", "C"], (
var_types[i] = b"B"
assert var_types[i] in [b"B", b"C"], (
"Only binary and continuous variables are currently supported. "
"Variable {var.varName} has type {vtype}."
f"Variable {var_names[i]} has type {var_types[i]}."
)
varname_to_var[var_names[i]] = gp_var
for (i, gp_constr) in enumerate(gp_constrs):
@ -671,7 +716,7 @@ class GurobiTestInstanceKnapsack(PyomoTestInstanceKnapsack):
self,
solver: InternalSolver,
model: Any,
violation: Hashable,
violation: str,
) -> None:
x0 = model.getVarByName("x[0]")
model.cbLazy(x0 <= 0)

@ -5,9 +5,11 @@
import logging
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any, List, Optional, List
from typing import Any, Optional, List, TYPE_CHECKING
import numpy as np
from scipy.sparse import coo_matrix
from miplearn.features import VariableFeatures, ConstraintFeatures
from miplearn.instance.base import Instance
from miplearn.types import (
IterationCallback,
@ -18,6 +20,9 @@ from miplearn.types import (
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from miplearn.features.sample import Sample
@dataclass
class LPSolveStats:
@ -44,20 +49,87 @@ class MIPSolveStats:
mip_warm_start_value: Optional[float] = None
@dataclass
class Variables:
names: Optional[np.ndarray] = None
basis_status: Optional[np.ndarray] = None
lower_bounds: Optional[np.ndarray] = None
obj_coeffs: Optional[np.ndarray] = None
reduced_costs: Optional[np.ndarray] = None
sa_lb_down: Optional[np.ndarray] = None
sa_lb_up: Optional[np.ndarray] = None
sa_obj_down: Optional[np.ndarray] = None
sa_obj_up: Optional[np.ndarray] = None
sa_ub_down: Optional[np.ndarray] = None
sa_ub_up: Optional[np.ndarray] = None
types: Optional[np.ndarray] = None
upper_bounds: Optional[np.ndarray] = None
values: Optional[np.ndarray] = None
@dataclass
class Constraints:
basis_status: Optional[np.ndarray] = None
dual_values: Optional[np.ndarray] = None
lazy: Optional[np.ndarray] = None
lhs: Optional[coo_matrix] = None
names: Optional[np.ndarray] = None
rhs: Optional[np.ndarray] = None
sa_rhs_down: Optional[np.ndarray] = None
sa_rhs_up: Optional[np.ndarray] = None
senses: Optional[np.ndarray] = None
slacks: Optional[np.ndarray] = None
@staticmethod
def from_sample(sample: "Sample") -> "Constraints":
return Constraints(
basis_status=sample.get_array("lp_constr_basis_status"),
dual_values=sample.get_array("lp_constr_dual_values"),
lazy=sample.get_array("static_constr_lazy"),
# lhs=sample.get_vector("static_constr_lhs"),
names=sample.get_array("static_constr_names"),
rhs=sample.get_array("static_constr_rhs"),
sa_rhs_down=sample.get_array("lp_constr_sa_rhs_down"),
sa_rhs_up=sample.get_array("lp_constr_sa_rhs_up"),
senses=sample.get_array("static_constr_senses"),
slacks=sample.get_array("lp_constr_slacks"),
)
def __getitem__(self, selected: List[bool]) -> "Constraints":
return Constraints(
basis_status=(
None if self.basis_status is None else self.basis_status[selected]
),
dual_values=(
None if self.dual_values is None else self.dual_values[selected]
),
names=(None if self.names is None else self.names[selected]),
lazy=(None if self.lazy is None else self.lazy[selected]),
lhs=(None if self.lhs is None else self.lhs.tocsr()[selected].tocoo()),
rhs=(None if self.rhs is None else self.rhs[selected]),
sa_rhs_down=(
None if self.sa_rhs_down is None else self.sa_rhs_down[selected]
),
sa_rhs_up=(None if self.sa_rhs_up is None else self.sa_rhs_up[selected]),
senses=(None if self.senses is None else self.senses[selected]),
slacks=(None if self.slacks is None else self.slacks[selected]),
)
class InternalSolver(ABC):
"""
Abstract class representing the MIP solver used internally by LearningSolver.
"""
@abstractmethod
def add_constraints(self, cf: ConstraintFeatures) -> None:
def add_constraints(self, cf: Constraints) -> None:
"""Adds the given constraints to the model."""
pass
@abstractmethod
def are_constraints_satisfied(
self,
cf: ConstraintFeatures,
cf: Constraints,
tol: float = 1e-5,
) -> List[bool]:
"""
@ -133,7 +205,7 @@ class InternalSolver(ABC):
with_static: bool = True,
with_sa: bool = True,
with_lhs: bool = True,
) -> ConstraintFeatures:
) -> Constraints:
pass
@abstractmethod
@ -149,7 +221,7 @@ class InternalSolver(ABC):
self,
with_static: bool = True,
with_sa: bool = True,
) -> VariableFeatures:
) -> Variables:
"""
Returns a description of the decision variables in the problem.
@ -176,7 +248,7 @@ class InternalSolver(ABC):
pass
@abstractmethod
def remove_constraints(self, names: List[str]) -> None:
def remove_constraints(self, names: np.ndarray) -> None:
"""
Removes the given constraints from the model.
"""

@ -3,8 +3,8 @@
# Released under the modified BSD license. See COPYING.md for more details.
import logging
import traceback
import time
import traceback
from typing import Optional, List, Any, cast, Dict, Tuple
from p_tqdm import p_map
@ -14,7 +14,7 @@ from miplearn.components.dynamic_lazy import DynamicLazyConstraintsComponent
from miplearn.components.dynamic_user_cuts import UserCutsComponent
from miplearn.components.objective import ObjectiveValueComponent
from miplearn.components.primal import PrimalSolutionComponent
from miplearn.features import FeaturesExtractor, Sample
from miplearn.features.extractor import FeaturesExtractor
from miplearn.instance.base import Instance
from miplearn.instance.picklegz import PickleGzInstance
from miplearn.solvers import _RedirectOutput
@ -149,8 +149,7 @@ class LearningSolver:
# Initialize training sample
# -------------------------------------------------------
sample = Sample()
instance.push_sample(sample)
sample = instance.create_sample()
# Initialize stats
# -------------------------------------------------------
@ -168,13 +167,13 @@ class LearningSolver:
# -------------------------------------------------------
logger.info("Extracting features (after-load)...")
initial_time = time.time()
features = self.extractor.extract(instance, self.internal_solver)
self.extractor.extract_after_load_features(
instance, self.internal_solver, sample
)
logger.info(
"Features (after-load) extracted in %.2f seconds"
% (time.time() - initial_time)
)
features.extra = {}
sample.after_load = features
callback_args = (
self,
@ -208,18 +207,13 @@ class LearningSolver:
# -------------------------------------------------------
logger.info("Extracting features (after-lp)...")
initial_time = time.time()
features = self.extractor.extract(
instance,
self.internal_solver,
with_static=False,
self.extractor.extract_after_lp_features(
self.internal_solver, sample, lp_stats
)
logger.info(
"Features (after-lp) extracted in %.2f seconds"
% (time.time() - initial_time)
)
features.extra = {}
features.lp_solve = lp_stats
sample.after_lp = features
# Callback wrappers
# -------------------------------------------------------
@ -281,18 +275,13 @@ class LearningSolver:
# -------------------------------------------------------
logger.info("Extracting features (after-mip)...")
initial_time = time.time()
features = self.extractor.extract(
instance,
self.internal_solver,
with_static=False,
)
for (k, v) in mip_stats.__dict__.items():
sample.put_scalar(k, v)
self.extractor.extract_after_mip_features(self.internal_solver, sample)
logger.info(
"Features (after-mip) extracted in %.2f seconds"
% (time.time() - initial_time)
)
features.mip_solve = mip_stats
features.extra = {}
sample.after_mip = features
# After-solve callbacks
# -------------------------------------------------------

@ -6,7 +6,7 @@ import logging
import re
import sys
from io import StringIO
from typing import Any, List, Dict, Optional, Tuple, Hashable
from typing import Any, List, Dict, Optional
import numpy as np
import pyomo
@ -18,8 +18,8 @@ from pyomo.core.base.constraint import ConstraintList
from pyomo.core.expr.numeric_expr import SumExpression, MonomialTermExpression
from pyomo.opt import TerminationCondition
from pyomo.opt.base.solvers import SolverFactory
from scipy.sparse import coo_matrix
from miplearn.features import VariableFeatures, ConstraintFeatures
from miplearn.instance.base import Instance
from miplearn.solvers import _RedirectOutput, _none_if_empty
from miplearn.solvers.internal import (
@ -28,13 +28,13 @@ from miplearn.solvers.internal import (
IterationCallback,
LazyCallback,
MIPSolveStats,
Variables,
Constraints,
)
from miplearn.types import (
SolverParams,
UserCutCallback,
Solution,
VariableName,
Category,
)
logger = logging.getLogger(__name__)
@ -58,7 +58,8 @@ class BasePyomoSolver(InternalSolver):
self._is_warm_start_available: bool = False
self._pyomo_solver: SolverFactory = solver_factory
self._obj_sense: str = "min"
self._varname_to_var: Dict[str, pe.Var] = {}
self._varname_to_var: Dict[bytes, pe.Var] = {}
self._varname_to_idx: Dict[str, int] = {}
self._cname_to_constr: Dict[str, pe.Constraint] = {}
self._termination_condition: str = ""
self._has_lp_solution = False
@ -79,27 +80,30 @@ class BasePyomoSolver(InternalSolver):
self._has_mip_solution = False
@overrides
def add_constraints(self, cf: ConstraintFeatures) -> None:
def add_constraints(self, cf: Constraints) -> None:
assert cf.names is not None
assert cf.senses is not None
assert cf.lhs is not None
assert cf.rhs is not None
assert self.model is not None
for (i, name) in enumerate(cf.names):
lhs = 0.0
for (varname, coeff) in cf.lhs[i]:
var = self._varname_to_var[varname]
lhs += var * coeff
if cf.senses[i] == "=":
expr = lhs == cf.rhs[i]
elif cf.senses[i] == "<":
expr = lhs <= cf.rhs[i]
lhs = cf.lhs.tocsr()
for i in range(len(cf.names)):
row = lhs[i, :]
lhsi = 0.0
for j in range(row.getnnz()):
lhsi += self._all_vars[row.indices[j]] * row.data[j]
if cf.senses[i] == b"=":
expr = lhsi == cf.rhs[i]
elif cf.senses[i] == b"<":
expr = lhsi <= cf.rhs[i]
elif cf.senses[i] == b">":
expr = lhsi >= cf.rhs[i]
else:
expr = lhs >= cf.rhs[i]
cl = pe.Constraint(expr=expr, name=name)
self.model.add_component(name, cl)
raise Exception(f"Unknown sense: {cf.senses[i]}")
cl = pe.Constraint(expr=expr, name=cf.names[i])
self.model.add_component(cf.names[i].decode(), cl)
self._pyomo_solver.add_constraint(cl)
self._cname_to_constr[name] = cl
self._cname_to_constr[cf.names[i]] = cl
self._termination_condition = ""
self._has_lp_solution = False
self._has_mip_solution = False
@ -111,25 +115,25 @@ class BasePyomoSolver(InternalSolver):
@overrides
def are_constraints_satisfied(
self,
cf: ConstraintFeatures,
cf: Constraints,
tol: float = 1e-5,
) -> List[bool]:
assert cf.names is not None
assert cf.lhs is not None
assert cf.rhs is not None
assert cf.senses is not None
x = [v.value for v in self._all_vars]
lhs = cf.lhs.tocsr() * x
result = []
for (i, name) in enumerate(cf.names):
lhs = 0.0
for (varname, coeff) in cf.lhs[i]:
var = self._varname_to_var[varname]
lhs += var.value * coeff
if cf.senses[i] == "<":
result.append(lhs <= cf.rhs[i] + tol)
elif cf.senses[i] == ">":
result.append(lhs >= cf.rhs[i] - tol)
for i in range(len(lhs)):
if cf.senses[i] == b"<":
result.append(lhs[i] <= cf.rhs[i] + tol)
elif cf.senses[i] == b">":
result.append(lhs[i] >= cf.rhs[i] - tol)
elif cf.senses[i] == b"=":
result.append(abs(cf.rhs[i] - lhs[i]) < tol)
else:
result.append(abs(cf.rhs[i] - lhs) < tol)
raise Exception(f"unknown sense: {cf.senses[i]}")
return result
@overrides
@ -159,18 +163,20 @@ class BasePyomoSolver(InternalSolver):
with_static: bool = True,
with_sa: bool = True,
with_lhs: bool = True,
) -> ConstraintFeatures:
) -> Constraints:
model = self.model
assert model is not None
names: List[str] = []
rhs: List[float] = []
lhs: List[List[Tuple[str, float]]] = []
senses: List[str] = []
dual_values: List[float] = []
slacks: List[float] = []
lhs_row: List[int] = []
lhs_col: List[int] = []
lhs_data: List[float] = []
lhs: Optional[coo_matrix] = None
def _parse_constraint(c: pe.Constraint) -> None:
def _parse_constraint(c: pe.Constraint, row: int) -> None:
assert model is not None
if with_static:
# Extract RHS and sense
@ -191,30 +197,31 @@ class BasePyomoSolver(InternalSolver):
if with_lhs:
# Extract LHS
lhsc = []
expr = c.body
if isinstance(expr, SumExpression):
for term in expr._args_:
if isinstance(term, MonomialTermExpression):
lhsc.append(
(
term._args_[1].name,
float(term._args_[0]),
)
lhs_row.append(row)
lhs_col.append(
self._varname_to_idx[term._args_[1].name]
)
lhs_data.append(float(term._args_[0]))
elif isinstance(term, _GeneralVarData):
lhsc.append((term.name, 1.0))
lhs_row.append(row)
lhs_col.append(self._varname_to_idx[term.name])
lhs_data.append(1.0)
else:
raise Exception(
f"Unknown term type: {term.__class__.__name__}"
)
elif isinstance(expr, _GeneralVarData):
lhsc.append((expr.name, 1.0))
lhs_row.append(row)
lhs_col.append(self._varname_to_idx[expr.name])
lhs_data.append(1.0)
else:
raise Exception(
f"Unknown expression type: {expr.__class__.__name__}"
)
lhs.append(lhsc)
# Extract dual values
if self._has_lp_solution:
@ -224,22 +231,28 @@ class BasePyomoSolver(InternalSolver):
if self._has_mip_solution or self._has_lp_solution:
slacks.append(model.slack[c])
for constr in model.component_objects(pyomo.core.Constraint):
curr_row = 0
for (i, constr) in enumerate(model.component_objects(pyomo.core.Constraint)):
if isinstance(constr, pe.ConstraintList):
for idx in constr:
names.append(f"{constr.name}[{idx}]")
_parse_constraint(constr[idx])
names.append(constr[idx].name)
_parse_constraint(constr[idx], curr_row)
curr_row += 1
else:
names.append(constr.name)
_parse_constraint(constr)
return ConstraintFeatures(
names=_none_if_empty(names),
rhs=_none_if_empty(rhs),
senses=_none_if_empty(senses),
lhs=_none_if_empty(lhs),
slacks=_none_if_empty(slacks),
dual_values=_none_if_empty(dual_values),
_parse_constraint(constr, curr_row)
curr_row += 1
if len(lhs_data) > 0:
lhs = coo_matrix((lhs_data, (lhs_row, lhs_col))).tocoo()
return Constraints(
names=_none_if_empty(np.array(names, dtype="S")),
rhs=_none_if_empty(np.array(rhs, dtype=float)),
senses=_none_if_empty(np.array(senses, dtype="S")),
lhs=lhs,
slacks=_none_if_empty(np.array(slacks, dtype=float)),
dual_values=_none_if_empty(np.array(dual_values, dtype=float)),
)
@overrides
@ -263,7 +276,7 @@ class BasePyomoSolver(InternalSolver):
for index in var:
if var[index].fixed:
continue
solution[f"{var}[{index}]"] = var[index].value
solution[var[index].name.encode()] = var[index].value
return solution
@overrides
@ -271,7 +284,7 @@ class BasePyomoSolver(InternalSolver):
self,
with_static: bool = True,
with_sa: bool = True,
) -> VariableFeatures:
) -> Variables:
assert self.model is not None
names: List[str] = []
@ -288,9 +301,9 @@ class BasePyomoSolver(InternalSolver):
# Variable name
if idx is None:
names.append(str(var))
names.append(var.name)
else:
names.append(f"{var}[{idx}]")
names.append(var[idx].name)
if with_static:
# Variable type
@ -326,14 +339,14 @@ class BasePyomoSolver(InternalSolver):
if self._has_lp_solution or self._has_mip_solution:
values.append(v.value)
return VariableFeatures(
names=_none_if_empty(names),
types=_none_if_empty(types),
upper_bounds=_none_if_empty(upper_bounds),
lower_bounds=_none_if_empty(lower_bounds),
obj_coeffs=_none_if_empty(obj_coeffs),
reduced_costs=_none_if_empty(reduced_costs),
values=_none_if_empty(values),
return Variables(
names=_none_if_empty(np.array(names, dtype="S")),
types=_none_if_empty(np.array(types, dtype="S")),
upper_bounds=_none_if_empty(np.array(upper_bounds, dtype=float)),
lower_bounds=_none_if_empty(np.array(lower_bounds, dtype=float)),
obj_coeffs=_none_if_empty(np.array(obj_coeffs, dtype=float)),
reduced_costs=_none_if_empty(np.array(reduced_costs, dtype=float)),
values=_none_if_empty(np.array(values, dtype=float)),
)
@overrides
@ -555,12 +568,14 @@ class BasePyomoSolver(InternalSolver):
self._all_vars = []
self._bin_vars = []
self._varname_to_var = {}
self._varname_to_idx = {}
for var in self.model.component_objects(Var):
for idx in var:
varname = f"{var.name}[{idx}]"
if idx is None:
varname = var.name
self._varname_to_var[varname] = var[idx]
varname = var.name
if idx is not None:
varname = var[idx].name
self._varname_to_var[varname.encode()] = var[idx]
self._varname_to_idx[varname] = len(self._all_vars)
self._all_vars += [var[idx]]
if var[idx].domain == pyomo.core.base.set_types.Binary:
self._bin_vars += [var[idx]]
@ -574,7 +589,7 @@ class BasePyomoSolver(InternalSolver):
for constr in self.model.component_objects(pyomo.core.Constraint):
if isinstance(constr, pe.ConstraintList):
for idx in constr:
self._cname_to_constr[f"{constr.name}[{idx}]"] = constr[idx]
self._cname_to_constr[constr[idx].name] = constr[idx]
else:
self._cname_to_constr[constr.name] = constr
@ -604,6 +619,7 @@ class PyomoTestInstanceKnapsack(Instance):
self.weights = weights
self.prices = prices
self.capacity = capacity
self.n = len(weights)
@overrides
def to_model(self) -> pe.ConcreteModel:
@ -621,22 +637,26 @@ class PyomoTestInstanceKnapsack(Instance):
return model
@overrides
def get_instance_features(self) -> List[float]:
return [
self.capacity,
np.average(self.weights),
]
def get_instance_features(self) -> np.ndarray:
return np.array(
[
self.capacity,
np.average(self.weights),
]
)
@overrides
def get_variable_features(self) -> Dict[str, List[float]]:
return {
f"x[{i}]": [
self.weights[i],
self.prices[i],
def get_variable_features(self, names: np.ndarray) -> np.ndarray:
return np.vstack(
[
[[self.weights[i], self.prices[i]] for i in range(self.n)],
[0.0, 0.0],
]
for i in range(len(self.weights))
}
)
@overrides
def get_variable_categories(self) -> Dict[str, Hashable]:
return {f"x[{i}]": "default" for i in range(len(self.weights))}
def get_variable_categories(self, names: np.ndarray) -> np.ndarray:
return np.array(
["default" if n.decode().startswith("x") else "" for n in names],
dtype="S",
)

@ -5,12 +5,13 @@
from typing import Any, List
import numpy as np
from scipy.sparse import coo_matrix
from miplearn.features import VariableFeatures, ConstraintFeatures
from miplearn.solvers.internal import InternalSolver
from miplearn.solvers.internal import InternalSolver, Variables, Constraints
inf = float("inf")
# NOTE:
# This file is in the main source folder, so that it can be called from Julia.
@ -40,31 +41,23 @@ def run_basic_usage_tests(solver: InternalSolver) -> None:
# Fetch variables (after-load)
assert_equals(
solver.get_variables(),
VariableFeatures(
names=["x[0]", "x[1]", "x[2]", "x[3]", "z"],
lower_bounds=[0.0, 0.0, 0.0, 0.0, 0.0],
upper_bounds=[1.0, 1.0, 1.0, 1.0, 67.0],
types=["B", "B", "B", "B", "C"],
obj_coeffs=[505.0, 352.0, 458.0, 220.0, 0.0],
Variables(
names=np.array(["x[0]", "x[1]", "x[2]", "x[3]", "z"], dtype="S"),
lower_bounds=np.array([0.0, 0.0, 0.0, 0.0, 0.0]),
upper_bounds=np.array([1.0, 1.0, 1.0, 1.0, 67.0]),
types=np.array(["B", "B", "B", "B", "C"], dtype="S"),
obj_coeffs=np.array([505.0, 352.0, 458.0, 220.0, 0.0]),
),
)
# Fetch constraints (after-load)
assert_equals(
solver.get_constraints(),
ConstraintFeatures(
names=["eq_capacity"],
rhs=[0.0],
lhs=[
[
("x[0]", 23.0),
("x[1]", 26.0),
("x[2]", 20.0),
("x[3]", 18.0),
("z", -1.0),
],
],
senses=["="],
Constraints(
names=np.array(["eq_capacity"], dtype="S"),
rhs=np.array([0.0]),
lhs=coo_matrix([[23.0, 26.0, 20.0, 18.0, -1.0]]),
senses=np.array(["="], dtype="S"),
),
)
@ -83,17 +76,21 @@ def run_basic_usage_tests(solver: InternalSolver) -> None:
solver.get_variables(with_static=False),
_filter_attrs(
solver.get_variable_attrs(),
VariableFeatures(
names=["x[0]", "x[1]", "x[2]", "x[3]", "z"],
basis_status=["U", "B", "U", "L", "U"],
reduced_costs=[193.615385, 0.0, 187.230769, -23.692308, 13.538462],
sa_lb_down=[-inf, -inf, -inf, -0.111111, -inf],
sa_lb_up=[1.0, 0.923077, 1.0, 1.0, 67.0],
sa_obj_down=[311.384615, 317.777778, 270.769231, -inf, -13.538462],
sa_obj_up=[inf, 570.869565, inf, 243.692308, inf],
sa_ub_down=[0.913043, 0.923077, 0.9, 0.0, 43.0],
sa_ub_up=[2.043478, inf, 2.2, inf, 69.0],
values=[1.0, 0.923077, 1.0, 0.0, 67.0],
Variables(
names=np.array(["x[0]", "x[1]", "x[2]", "x[3]", "z"], dtype="S"),
basis_status=np.array(["U", "B", "U", "L", "U"], dtype="S"),
reduced_costs=np.array(
[193.615385, 0.0, 187.230769, -23.692308, 13.538462]
),
sa_lb_down=np.array([-inf, -inf, -inf, -0.111111, -inf]),
sa_lb_up=np.array([1.0, 0.923077, 1.0, 1.0, 67.0]),
sa_obj_down=np.array(
[311.384615, 317.777778, 270.769231, -inf, -13.538462]
),
sa_obj_up=np.array([inf, 570.869565, inf, 243.692308, inf]),
sa_ub_down=np.array([0.913043, 0.923077, 0.9, 0.0, 43.0]),
sa_ub_up=np.array([2.043478, inf, 2.2, inf, 69.0]),
values=np.array([1.0, 0.923077, 1.0, 0.0, 67.0]),
),
),
)
@ -103,13 +100,13 @@ def run_basic_usage_tests(solver: InternalSolver) -> None:
solver.get_constraints(with_static=False),
_filter_attrs(
solver.get_constraint_attrs(),
ConstraintFeatures(
basis_status=["N"],
dual_values=[13.538462],
names=["eq_capacity"],
sa_rhs_down=[-24.0],
sa_rhs_up=[2.0],
slacks=[0.0],
Constraints(
basis_status=np.array(["N"], dtype="S"),
dual_values=np.array([13.538462]),
names=np.array(["eq_capacity"], dtype="S"),
sa_rhs_down=np.array([-24.0]),
sa_rhs_up=np.array([2.0]),
slacks=np.array([0.0]),
),
),
)
@ -136,9 +133,9 @@ def run_basic_usage_tests(solver: InternalSolver) -> None:
solver.get_variables(with_static=False),
_filter_attrs(
solver.get_variable_attrs(),
VariableFeatures(
names=["x[0]", "x[1]", "x[2]", "x[3]", "z"],
values=[1.0, 0.0, 1.0, 1.0, 61.0],
Variables(
names=np.array(["x[0]", "x[1]", "x[2]", "x[3]", "z"], dtype="S"),
values=np.array([1.0, 0.0, 1.0, 1.0, 61.0]),
),
),
)
@ -148,19 +145,19 @@ def run_basic_usage_tests(solver: InternalSolver) -> None:
solver.get_constraints(with_static=False),
_filter_attrs(
solver.get_constraint_attrs(),
ConstraintFeatures(
names=["eq_capacity"],
slacks=[0.0],
Constraints(
names=np.array(["eq_capacity"], dtype="S"),
slacks=np.array([0.0]),
),
),
)
# Build new constraint and verify that it is violated
cf = ConstraintFeatures(
names=["cut"],
lhs=[[("x[0]", 1.0)]],
rhs=[0.0],
senses=["<"],
cf = Constraints(
names=np.array(["cut"], dtype="S"),
lhs=coo_matrix([[1.0, 0.0, 0.0, 0.0, 0.0]]),
rhs=np.array([0.0]),
senses=np.array(["<"], dtype="S"),
)
assert_equals(solver.are_constraints_satisfied(cf), [False])
@ -170,22 +167,16 @@ def run_basic_usage_tests(solver: InternalSolver) -> None:
solver.get_constraints(with_static=True),
_filter_attrs(
solver.get_constraint_attrs(),
ConstraintFeatures(
names=["eq_capacity", "cut"],
rhs=[0.0, 0.0],
lhs=[
[
("x[0]", 23.0),
("x[1]", 26.0),
("x[2]", 20.0),
("x[3]", 18.0),
("z", -1.0),
],
Constraints(
names=np.array(["eq_capacity", "cut"], dtype="S"),
rhs=np.array([0.0, 0.0]),
lhs=coo_matrix(
[
("x[0]", 1.0),
],
],
senses=["=", "<"],
[23.0, 26.0, 20.0, 18.0, -1.0],
[1.0, 0.0, 0.0, 0.0, 0.0],
]
),
senses=np.array(["=", "<"], dtype="S"),
),
),
)
@ -194,7 +185,7 @@ def run_basic_usage_tests(solver: InternalSolver) -> None:
assert_equals(solver.are_constraints_satisfied(cf), [True])
# Remove the new constraint
solver.remove_constraints(["cut"])
solver.remove_constraints(np.array(["cut"], dtype="S"))
# New constraint should no longer affect solution
stats = solver.solve()
@ -205,16 +196,16 @@ def run_warm_start_tests(solver: InternalSolver) -> None:
instance = solver.build_test_instance_knapsack()
model = instance.to_model()
solver.set_instance(instance, model)
solver.set_warm_start({"x[0]": 1.0, "x[1]": 0.0, "x[2]": 0.0, "x[3]": 1.0})
solver.set_warm_start({b"x[0]": 1.0, b"x[1]": 0.0, b"x[2]": 0.0, b"x[3]": 1.0})
stats = solver.solve(tee=True)
if stats.mip_warm_start_value is not None:
assert_equals(stats.mip_warm_start_value, 725.0)
solver.set_warm_start({"x[0]": 1.0, "x[1]": 1.0, "x[2]": 1.0, "x[3]": 1.0})
solver.set_warm_start({b"x[0]": 1.0, b"x[1]": 1.0, b"x[2]": 1.0, b"x[3]": 1.0})
stats = solver.solve(tee=True)
assert stats.mip_warm_start_value is None
solver.fix({"x[0]": 1.0, "x[1]": 0.0, "x[2]": 0.0, "x[3]": 1.0})
solver.fix({b"x[0]": 1.0, b"x[1]": 0.0, b"x[2]": 0.0, b"x[3]": 1.0})
stats = solver.solve(tee=True)
assert_equals(stats.mip_lower_bound, 725.0)
assert_equals(stats.mip_upper_bound, 725.0)
@ -254,15 +245,15 @@ def run_lazy_cb_tests(solver: InternalSolver) -> None:
def lazy_cb(cb_solver: InternalSolver, cb_model: Any) -> None:
relsol = cb_solver.get_solution()
assert relsol is not None
assert relsol["x[0]"] is not None
if relsol["x[0]"] > 0:
instance.enforce_lazy_constraint(cb_solver, cb_model, "cut")
assert relsol[b"x[0]"] is not None
if relsol[b"x[0]"] > 0:
instance.enforce_lazy_constraint(cb_solver, cb_model, b"cut")
solver.set_instance(instance, model)
solver.solve(lazy_cb=lazy_cb)
solution = solver.get_solution()
assert solution is not None
assert_equals(solution["x[0]"], 0.0)
assert_equals(solution[b"x[0]"], 0.0)
def _equals_preprocess(obj: Any) -> Any:
@ -271,7 +262,9 @@ def _equals_preprocess(obj: Any) -> Any:
return np.round(obj, decimals=6).tolist()
else:
return obj.tolist()
elif isinstance(obj, (int, str)):
elif isinstance(obj, coo_matrix):
return obj.todense().tolist()
elif isinstance(obj, (int, str, bool, np.bool_, np.bytes_, bytes, bytearray)):
return obj
elif isinstance(obj, float):
return round(obj, 6)

@ -2,7 +2,7 @@
# 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 Optional, Dict, Callable, Any, Union, TYPE_CHECKING, Hashable
from typing import Optional, Dict, Callable, Any, Union, TYPE_CHECKING
from mypy_extensions import TypedDict
@ -10,13 +10,14 @@ if TYPE_CHECKING:
# noinspection PyUnresolvedReferences
from miplearn.solvers.learning import InternalSolver
Category = Hashable
Category = bytes
ConstraintName = bytes
ConstraintCategory = bytes
IterationCallback = Callable[[], bool]
LazyCallback = Callable[[Any, Any], None]
SolverParams = Dict[str, Any]
UserCutCallback = Callable[["InternalSolver", Any], None]
VariableName = str
Solution = Dict[VariableName, Optional[float]]
Solution = Dict[bytes, Optional[float]]
LearningSolveStats = TypedDict(
"LearningSolveStats",

@ -9,7 +9,7 @@ with open("README.md", "r") as fh:
setup(
name="miplearn",
version="0.2.0.dev10",
version="0.2.0.dev11",
author="Alinson S. Xavier",
author_email="axavier@anl.gov",
description="Extensible framework for Learning-Enhanced Mixed-Integer Optimization",
@ -19,20 +19,21 @@ setup(
packages=find_namespace_packages(),
python_requires=">=3.7",
install_requires=[
"decorator>=4,<5",
"h5py>=3,<4",
"matplotlib>=3,<4",
"mypy==0.790",
"networkx>=2,<3",
"numpy>=1,<1.21",
"overrides>=3,<4",
"p_tqdm>=1,<2",
"pandas>=1,<2",
"pyomo>=5,<6",
"pytest>=6,<7",
"python-markdown-math>=0.8,<0.9",
"seaborn>=0.11,<0.12",
"scikit-learn>=0.24,<0.25",
"seaborn>=0.11,<0.12",
"tqdm>=4,<5",
"mypy==0.790",
"decorator>=4,<5",
"overrides>=3,<4",
],
extras_require={
"dev": [

@ -11,11 +11,7 @@ from miplearn.classifiers import Classifier
from miplearn.classifiers.threshold import MinProbabilityThreshold
from miplearn.components import classifier_evaluation_dict
from miplearn.components.dynamic_lazy import DynamicLazyConstraintsComponent
from miplearn.features import (
Features,
InstanceFeatures,
Sample,
)
from miplearn.features.sample import MemorySample
from miplearn.instance.base import Instance
from miplearn.solvers.tests import assert_equals
@ -26,70 +22,78 @@ E = 0.1
def training_instances() -> List[Instance]:
instances = [cast(Instance, Mock(spec=Instance)) for _ in range(2)]
samples_0 = [
Sample(
after_load=Features(instance=InstanceFeatures()),
after_mip=Features(extra={"lazy_enforced": {"c1", "c2"}}),
MemorySample(
{
"mip_constr_lazy_enforced": np.array(["c1", "c2"], dtype="S"),
"static_instance_features": np.array([5.0]),
},
),
Sample(
after_load=Features(instance=InstanceFeatures()),
after_mip=Features(extra={"lazy_enforced": {"c2", "c3"}}),
MemorySample(
{
"mip_constr_lazy_enforced": np.array(["c2", "c3"], dtype="S"),
"static_instance_features": np.array([5.0]),
},
),
]
samples_0[0].after_load.instance.to_list = Mock(return_value=[5.0]) # type: ignore
samples_0[1].after_load.instance.to_list = Mock(return_value=[5.0]) # type: ignore
instances[0].get_samples = Mock(return_value=samples_0) # type: ignore
instances[0].get_constraint_categories = Mock( # type: ignore
return_value={
"c1": "type-a",
"c2": "type-a",
"c3": "type-b",
"c4": "type-b",
}
return_value=np.array(["type-a", "type-a", "type-b", "type-b"], dtype="S")
)
instances[0].get_constraint_features = Mock( # type: ignore
return_value={
"c1": [1.0, 2.0, 3.0],
"c2": [4.0, 5.0, 6.0],
"c3": [1.0, 2.0],
"c4": [3.0, 4.0],
}
return_value=np.array(
[
[1.0, 2.0, 3.0],
[4.0, 5.0, 6.0],
[1.0, 2.0, 0.0],
[3.0, 4.0, 0.0],
]
)
)
instances[0].are_constraints_lazy = Mock( # type: ignore
return_value=np.zeros(4, dtype=bool)
)
samples_1 = [
Sample(
after_load=Features(instance=InstanceFeatures()),
after_mip=Features(extra={"lazy_enforced": {"c3", "c4"}}),
MemorySample(
{
"mip_constr_lazy_enforced": np.array(["c3", "c4"], dtype="S"),
"static_instance_features": np.array([8.0]),
},
)
]
samples_1[0].after_load.instance.to_list = Mock(return_value=[8.0]) # type: ignore
instances[1].get_samples = Mock(return_value=samples_1) # type: ignore
instances[1].get_constraint_categories = Mock( # type: ignore
return_value={
"c1": None,
"c2": "type-a",
"c3": "type-b",
"c4": "type-b",
}
return_value=np.array(["", "type-a", "type-b", "type-b"], dtype="S")
)
instances[1].get_constraint_features = Mock( # type: ignore
return_value={
"c2": [7.0, 8.0, 9.0],
"c3": [5.0, 6.0],
"c4": [7.0, 8.0],
}
return_value=np.array(
[
[7.0, 8.0, 9.0],
[5.0, 6.0, 0.0],
[7.0, 8.0, 0.0],
]
)
)
instances[1].are_constraints_lazy = Mock( # type: ignore
return_value=np.zeros(4, dtype=bool)
)
return instances
def test_sample_xy(training_instances: List[Instance]) -> None:
comp = DynamicLazyConstraintsComponent()
comp.pre_fit([{"c1", "c2", "c3", "c4"}])
comp.pre_fit(
[
np.array(["c1", "c3", "c4"], dtype="S"),
np.array(["c1", "c2", "c4"], dtype="S"),
]
)
x_expected = {
"type-a": [[5.0, 1.0, 2.0, 3.0], [5.0, 4.0, 5.0, 6.0]],
"type-b": [[5.0, 1.0, 2.0], [5.0, 3.0, 4.0]],
b"type-a": np.array([[5.0, 1.0, 2.0, 3.0], [5.0, 4.0, 5.0, 6.0]]),
b"type-b": np.array([[5.0, 1.0, 2.0, 0.0], [5.0, 3.0, 4.0, 0.0]]),
}
y_expected = {
"type-a": [[False, True], [False, True]],
"type-b": [[True, False], [True, False]],
b"type-a": np.array([[False, True], [False, True]]),
b"type-b": np.array([[True, False], [True, False]]),
}
x_actual, y_actual = comp.sample_xy(
training_instances[0],
@ -99,95 +103,26 @@ def test_sample_xy(training_instances: List[Instance]) -> None:
assert_equals(y_actual, y_expected)
# def test_fit(training_instances: List[Instance]) -> None:
# clf = Mock(spec=Classifier)
# clf.clone = Mock(side_effect=lambda: Mock(spec=Classifier))
# comp = DynamicLazyConstraintsComponent(classifier=clf)
# comp.fit(training_instances)
# assert clf.clone.call_count == 2
#
# assert "type-a" in comp.classifiers
# clf_a = comp.classifiers["type-a"]
# assert clf_a.fit.call_count == 1 # type: ignore
# assert_array_equal(
# clf_a.fit.call_args[0][0], # type: ignore
# np.array(
# [
# [5.0, 1.0, 2.0, 3.0],
# [5.0, 4.0, 5.0, 6.0],
# [5.0, 1.0, 2.0, 3.0],
# [5.0, 4.0, 5.0, 6.0],
# [8.0, 7.0, 8.0, 9.0],
# ]
# ),
# )
# assert_array_equal(
# clf_a.fit.call_args[0][1], # type: ignore
# np.array(
# [
# [False, True],
# [False, True],
# [True, False],
# [False, True],
# [True, False],
# ]
# ),
# )
#
# assert "type-b" in comp.classifiers
# clf_b = comp.classifiers["type-b"]
# assert clf_b.fit.call_count == 1 # type: ignore
# assert_array_equal(
# clf_b.fit.call_args[0][0], # type: ignore
# np.array(
# [
# [5.0, 1.0, 2.0],
# [5.0, 3.0, 4.0],
# [5.0, 1.0, 2.0],
# [5.0, 3.0, 4.0],
# [8.0, 5.0, 6.0],
# [8.0, 7.0, 8.0],
# ]
# ),
# )
# assert_array_equal(
# clf_b.fit.call_args[0][1], # type: ignore
# np.array(
# [
# [True, False],
# [True, False],
# [False, True],
# [True, False],
# [False, True],
# [False, True],
# ]
# ),
# )
def test_sample_predict_evaluate(training_instances: List[Instance]) -> None:
comp = DynamicLazyConstraintsComponent()
comp.known_cids.extend(["c1", "c2", "c3", "c4"])
comp.thresholds["type-a"] = MinProbabilityThreshold([0.5, 0.5])
comp.thresholds["type-b"] = MinProbabilityThreshold([0.5, 0.5])
comp.classifiers["type-a"] = Mock(spec=Classifier)
comp.classifiers["type-b"] = Mock(spec=Classifier)
comp.classifiers["type-a"].predict_proba = Mock( # type: ignore
comp.known_cids.extend([b"c1", b"c2", b"c3", b"c4"])
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)
comp.classifiers[b"type-b"] = Mock(spec=Classifier)
comp.classifiers[b"type-a"].predict_proba = Mock( # type: ignore
side_effect=lambda _: np.array([[0.1, 0.9], [0.8, 0.2]])
)
comp.classifiers["type-b"].predict_proba = Mock( # type: ignore
comp.classifiers[b"type-b"].predict_proba = Mock( # type: ignore
side_effect=lambda _: np.array([[0.9, 0.1], [0.1, 0.9]])
)
pred = comp.sample_predict(
training_instances[0],
training_instances[0].get_samples()[0],
)
assert pred == ["c1", "c4"]
assert pred == [b"c1", b"c4"]
ev = comp.sample_evaluate(
training_instances[0],
training_instances[0].get_samples()[0],
)
assert ev == {
"type-a": classifier_evaluation_dict(tp=1, fp=0, tn=0, fn=1),
"type-b": classifier_evaluation_dict(tp=0, fp=1, tn=1, fn=0),
}
assert ev == classifier_evaluation_dict(tp=1, fp=1, tn=1, fn=1)

@ -3,7 +3,7 @@
# Released under the modified BSD license. See COPYING.md for more details.
import logging
from typing import Any, FrozenSet, Hashable, List
from typing import Any, FrozenSet, List
import gurobipy as gp
import networkx as nx
@ -17,6 +17,7 @@ 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
logger = logging.getLogger(__name__)
@ -40,13 +41,13 @@ class GurobiStableSetProblem(Instance):
return True
@overrides
def find_violated_user_cuts(self, model: Any) -> List[FrozenSet]:
def find_violated_user_cuts(self, model: Any) -> List[ConstraintName]:
assert isinstance(model, gp.Model)
vals = model.cbGetNodeRel(model.getVars())
violations = []
for clique in nx.find_cliques(self.graph):
if sum(vals[i] for i in clique) > 1:
violations += [frozenset(clique)]
violations.append(",".join([str(i) for i in clique]).encode())
return violations
@overrides
@ -54,11 +55,11 @@ class GurobiStableSetProblem(Instance):
self,
solver: InternalSolver,
model: Any,
cid: Hashable,
cid: ConstraintName,
) -> Any:
assert isinstance(cid, FrozenSet)
clique = [int(i) for i in cid.decode().split(",")]
x = model.getVars()
model.addConstr(gp.quicksum([x[i] for i in cid]) <= 1)
model.addConstr(gp.quicksum([x[i] for i in clique]) <= 1)
@pytest.fixture
@ -81,10 +82,9 @@ def test_usage(
) -> None:
stats_before = solver.solve(stab_instance)
sample = stab_instance.get_samples()[0]
assert sample.after_mip is not None
assert sample.after_mip.extra is not None
assert len(sample.after_mip.extra["user_cuts_enforced"]) > 0
print(stats_before)
user_cuts_enforced = sample.get_array("mip_user_cuts_enforced")
assert user_cuts_enforced is not None
assert len(user_cuts_enforced) > 0
assert stats_before["UserCuts: Added ahead-of-time"] == 0
assert stats_before["UserCuts: Added in callback"] > 0

@ -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 Hashable, Dict
from typing import Dict
from unittest.mock import Mock
import numpy as np
@ -10,55 +10,46 @@ from numpy.testing import assert_array_equal
from miplearn.classifiers import Regressor
from miplearn.components.objective import ObjectiveValueComponent
from miplearn.features import InstanceFeatures, Features, Sample
from miplearn.solvers.internal import MIPSolveStats, LPSolveStats
from miplearn.features.sample import Sample, MemorySample
from miplearn.solvers.learning import LearningSolver
from miplearn.solvers.pyomo.gurobi import GurobiPyomoSolver
from miplearn.solvers.tests import assert_equals
@pytest.fixture
def sample() -> Sample:
sample = Sample(
after_load=Features(
instance=InstanceFeatures(),
),
after_lp=Features(
lp_solve=LPSolveStats(),
),
after_mip=Features(
mip_solve=MIPSolveStats(
mip_lower_bound=1.0,
mip_upper_bound=2.0,
)
),
sample = MemorySample(
{
"mip_lower_bound": 1.0,
"mip_upper_bound": 2.0,
"lp_instance_features": np.array([1.0, 2.0, 3.0]),
},
)
sample.after_load.instance.to_list = Mock(return_value=[1.0, 2.0]) # type: ignore
sample.after_lp.lp_solve.to_list = Mock(return_value=[3.0]) # type: ignore
return sample
def test_sample_xy(sample: Sample) -> None:
x_expected = {
"Lower bound": [[1.0, 2.0, 3.0]],
"Upper bound": [[1.0, 2.0, 3.0]],
"Lower bound": np.array([[1.0, 2.0, 3.0]]),
"Upper bound": np.array([[1.0, 2.0, 3.0]]),
}
y_expected = {
"Lower bound": [[1.0]],
"Upper bound": [[2.0]],
"Lower bound": np.array([[1.0]]),
"Upper bound": np.array([[2.0]]),
}
xy = ObjectiveValueComponent().sample_xy(None, sample)
assert xy is not None
x_actual, y_actual = xy
assert x_actual == x_expected
assert y_actual == y_expected
assert_equals(x_actual, x_expected)
assert_equals(y_actual, y_expected)
def test_fit_xy() -> None:
x: Dict[Hashable, np.ndarray] = {
x: Dict[str, np.ndarray] = {
"Lower bound": np.array([[0.0, 0.0], [1.0, 2.0]]),
"Upper bound": np.array([[0.0, 0.0], [1.0, 2.0]]),
}
y: Dict[Hashable, np.ndarray] = {
y: Dict[str, np.ndarray] = {
"Lower bound": np.array([[100.0]]),
"Upper bound": np.array([[200.0]]),
}

@ -12,12 +12,7 @@ from miplearn.classifiers import Classifier
from miplearn.classifiers.threshold import Threshold
from miplearn.components import classifier_evaluation_dict
from miplearn.components.primal import PrimalSolutionComponent
from miplearn.features import (
Features,
Sample,
InstanceFeatures,
VariableFeatures,
)
from miplearn.features.sample import Sample, MemorySample
from miplearn.problems.tsp import TravelingSalesmanGenerator
from miplearn.solvers.learning import LearningSolver
from miplearn.solvers.tests import assert_equals
@ -25,54 +20,46 @@ from miplearn.solvers.tests import assert_equals
@pytest.fixture
def sample() -> Sample:
sample = Sample(
after_load=Features(
instance=InstanceFeatures(),
variables=VariableFeatures(
names=["x[0]", "x[1]", "x[2]", "x[3]"],
categories=["default", None, "default", "default"],
sample = MemorySample(
{
"static_var_names": np.array(["x[0]", "x[1]", "x[2]", "x[3]"], dtype="S"),
"static_var_categories": np.array(
["default", "", "default", "default"],
dtype="S",
),
),
after_lp=Features(
variables=VariableFeatures(),
),
after_mip=Features(
variables=VariableFeatures(
names=["x[0]", "x[1]", "x[2]", "x[3]"],
values=[0.0, 1.0, 1.0, 0.0],
)
),
)
sample.after_load.instance.to_list = Mock(return_value=[5.0]) # type: ignore
sample.after_load.variables.to_list = Mock( # type:ignore
side_effect=lambda i: [
[0.0, 0.0],
None,
[1.0, 0.0],
[1.0, 1.0],
][i]
)
sample.after_lp.variables.to_list = Mock( # type:ignore
side_effect=lambda i: [
[2.0, 2.0],
None,
[3.0, 2.0],
[3.0, 3.0],
][i]
"mip_var_values": np.array([0.0, 1.0, 1.0, 0.0]),
"static_instance_features": np.array([5.0]),
"static_var_features": np.array(
[
[0.0, 0.0],
[0.0, 0.0],
[1.0, 0.0],
[1.0, 1.0],
]
),
"lp_var_features": np.array(
[
[0.0, 0.0, 2.0, 2.0],
[0.0, 0.0, 0.0, 0.0],
[1.0, 0.0, 3.0, 2.0],
[1.0, 1.0, 3.0, 3.0],
]
),
},
)
return sample
def test_xy(sample: Sample) -> None:
x_expected = {
"default": [
b"default": [
[5.0, 0.0, 0.0, 2.0, 2.0],
[5.0, 1.0, 0.0, 3.0, 2.0],
[5.0, 1.0, 1.0, 3.0, 3.0],
]
}
y_expected = {
"default": [
b"default": [
[True, False],
[False, True],
[True, False],
@ -92,15 +79,15 @@ def test_fit_xy() -> None:
thr.clone = lambda: Mock(spec=Threshold)
comp = PrimalSolutionComponent(classifier=clf, threshold=thr)
x = {
"type-a": np.array([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]),
"type-b": np.array([[7.0, 8.0, 9.0]]),
b"type-a": np.array([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]),
b"type-b": np.array([[7.0, 8.0, 9.0]]),
}
y = {
"type-a": np.array([[True, False], [False, True]]),
"type-b": np.array([[True, False]]),
b"type-a": np.array([[True, False], [False, True]]),
b"type-b": np.array([[True, False]]),
}
comp.fit_xy(x, y)
for category in ["type-a", "type-b"]:
for category in [b"type-a", b"type-b"]:
assert category in comp.classifiers
assert category in comp.thresholds
clf = comp.classifiers[category] # type: ignore
@ -132,17 +119,17 @@ def test_usage() -> None:
def test_evaluate(sample: Sample) -> None:
comp = PrimalSolutionComponent()
comp.sample_predict = lambda _: { # type: ignore
"x[0]": 1.0,
"x[1]": 1.0,
"x[2]": 0.0,
"x[3]": None,
b"x[0]": 1.0,
b"x[1]": 1.0,
b"x[2]": 0.0,
b"x[3]": None,
}
ev = comp.sample_evaluate(None, sample)
assert_equals(
ev,
{
0: classifier_evaluation_dict(tp=0, fp=1, tn=1, fn=2),
1: classifier_evaluation_dict(tp=1, fp=1, tn=1, fn=1),
"0": classifier_evaluation_dict(tp=0, fp=1, tn=1, fn=2),
"1": classifier_evaluation_dict(tp=1, fp=1, tn=1, fn=1),
},
)
@ -162,16 +149,16 @@ def test_predict(sample: Sample) -> None:
thr.predict = Mock(return_value=[0.75, 0.75])
comp = PrimalSolutionComponent()
x, _ = comp.sample_xy(None, sample)
comp.classifiers = {"default": clf}
comp.thresholds = {"default": thr}
comp.classifiers = {b"default": clf}
comp.thresholds = {b"default": thr}
pred = comp.sample_predict(sample)
clf.predict_proba.assert_called_once()
thr.predict.assert_called_once()
assert_array_equal(x["default"], clf.predict_proba.call_args[0][0])
assert_array_equal(x["default"], thr.predict.call_args[0][0])
assert_array_equal(x[b"default"], clf.predict_proba.call_args[0][0])
assert_array_equal(x[b"default"], thr.predict.call_args[0][0])
assert pred == {
"x[0]": 0.0,
"x[1]": None,
"x[2]": None,
"x[3]": 1.0,
b"x[0]": 0.0,
b"x[1]": None,
b"x[2]": None,
b"x[3]": 1.0,
}

@ -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 Dict, cast, Hashable
from typing import Dict, cast
from unittest.mock import Mock, call
import numpy as np
@ -11,58 +11,43 @@ from numpy.testing import assert_array_equal
from miplearn.classifiers import Classifier
from miplearn.classifiers.threshold import Threshold, MinProbabilityThreshold
from miplearn.components.static_lazy import StaticLazyConstraintsComponent
from miplearn.features import (
InstanceFeatures,
Features,
Sample,
ConstraintFeatures,
)
from miplearn.features.sample import Sample, MemorySample
from miplearn.instance.base import Instance
from miplearn.solvers.internal import InternalSolver
from miplearn.solvers.internal import InternalSolver, Constraints
from miplearn.solvers.learning import LearningSolver
from miplearn.types import (
LearningSolveStats,
ConstraintCategory,
)
from miplearn.solvers.tests import assert_equals
@pytest.fixture
def sample() -> Sample:
sample = Sample(
after_load=Features(
instance=InstanceFeatures(
lazy_constraint_count=4,
),
constraints=ConstraintFeatures(
names=["c1", "c2", "c3", "c4", "c5"],
categories=[
"type-a",
"type-a",
"type-a",
"type-b",
"type-b",
],
lazy=[True, True, True, True, False],
sample = MemorySample(
{
"static_constr_categories": [
b"type-a",
b"type-a",
b"type-a",
b"type-b",
b"type-b",
],
"static_constr_lazy": np.array([True, True, True, True, False]),
"static_constr_names": np.array(["c1", "c2", "c3", "c4", "c5"], dtype="S"),
"static_instance_features": [5.0],
"mip_constr_lazy_enforced": np.array(["c1", "c2", "c4"], dtype="S"),
"lp_constr_features": np.array(
[
[1.0, 1.0, 0.0],
[1.0, 2.0, 0.0],
[1.0, 3.0, 0.0],
[1.0, 4.0, 0.0],
[0.0, 0.0, 0.0],
]
),
),
after_lp=Features(
instance=InstanceFeatures(),
constraints=ConstraintFeatures(names=["c1", "c2", "c3", "c4", "c5"]),
),
after_mip=Features(
extra={
"lazy_enforced": {"c1", "c2", "c4"},
}
),
)
sample.after_lp.instance.to_list = Mock(return_value=[5.0]) # type: ignore
sample.after_lp.constraints.to_list = Mock( # type: ignore
side_effect=lambda idx: {
0: [1.0, 1.0],
1: [1.0, 2.0],
2: [1.0, 3.0],
3: [1.0, 4.0, 0.0],
4: None,
}[idx]
"static_constr_lazy_count": 4,
},
)
return sample
@ -87,13 +72,13 @@ def test_usage_with_solver(instance: Instance) -> None:
)
component = StaticLazyConstraintsComponent(violation_tolerance=1.0)
component.thresholds["type-a"] = MinProbabilityThreshold([0.5, 0.5])
component.thresholds["type-b"] = MinProbabilityThreshold([0.5, 0.5])
component.thresholds[b"type-a"] = MinProbabilityThreshold([0.5, 0.5])
component.thresholds[b"type-b"] = MinProbabilityThreshold([0.5, 0.5])
component.classifiers = {
"type-a": Mock(spec=Classifier),
"type-b": Mock(spec=Classifier),
b"type-a": Mock(spec=Classifier),
b"type-b": Mock(spec=Classifier),
}
component.classifiers["type-a"].predict_proba = Mock( # type: ignore
component.classifiers[b"type-a"].predict_proba = Mock( # type: ignore
return_value=np.array(
[
[0.00, 1.00], # c1
@ -102,7 +87,7 @@ def test_usage_with_solver(instance: Instance) -> None:
]
)
)
component.classifiers["type-b"].predict_proba = Mock( # type: ignore
component.classifiers[b"type-b"].predict_proba = Mock( # type: ignore
return_value=np.array(
[
[0.02, 0.98], # c4
@ -112,10 +97,7 @@ def test_usage_with_solver(instance: Instance) -> None:
stats: LearningSolveStats = {}
sample = instance.get_samples()[0]
assert sample.after_load is not None
assert sample.after_mip is not None
assert sample.after_mip.extra is not None
del sample.after_mip.extra["lazy_enforced"]
assert sample.get_array("mip_constr_lazy_enforced") is not None
# LearningSolver calls before_solve_mip
component.before_solve_mip(
@ -127,12 +109,12 @@ def test_usage_with_solver(instance: Instance) -> None:
)
# Should ask ML to predict whether each lazy constraint should be enforced
component.classifiers["type-a"].predict_proba.assert_called_once()
component.classifiers["type-b"].predict_proba.assert_called_once()
component.classifiers[b"type-a"].predict_proba.assert_called_once()
component.classifiers[b"type-b"].predict_proba.assert_called_once()
# Should ask internal solver to remove some constraints
assert internal.remove_constraints.call_count == 1
internal.remove_constraints.assert_has_calls([call(["c3"])])
internal.remove_constraints.assert_has_calls([call([b"c3"])])
# LearningSolver calls after_iteration (first time)
should_repeat = component.iteration_cb(solver, instance, None)
@ -140,8 +122,7 @@ def test_usage_with_solver(instance: Instance) -> None:
# Should ask internal solver to verify if constraints in the pool are
# satisfied and add the ones that are not
assert sample.after_load.constraints is not None
c = sample.after_load.constraints[[False, False, True, False, False]]
c = Constraints.from_sample(sample)[[False, False, True, False, False]]
internal.are_constraints_satisfied.assert_called_once_with(c, tol=1.0)
internal.are_constraints_satisfied.reset_mock()
internal.add_constraints.assert_called_once_with(c)
@ -165,8 +146,13 @@ def test_usage_with_solver(instance: Instance) -> None:
)
# Should update training sample
assert sample.after_mip.extra["lazy_enforced"] == {"c1", "c2", "c3", "c4"}
#
mip_constr_lazy_enforced = sample.get_array("mip_constr_lazy_enforced")
assert mip_constr_lazy_enforced is not None
assert_equals(
sorted(mip_constr_lazy_enforced),
np.array(["c1", "c2", "c3", "c4"], dtype="S"),
)
# Should update stats
assert stats["LazyStatic: Removed"] == 1
assert stats["LazyStatic: Kept"] == 3
@ -176,39 +162,39 @@ def test_usage_with_solver(instance: Instance) -> None:
def test_sample_predict(sample: Sample) -> None:
comp = StaticLazyConstraintsComponent()
comp.thresholds["type-a"] = MinProbabilityThreshold([0.5, 0.5])
comp.thresholds["type-b"] = MinProbabilityThreshold([0.5, 0.5])
comp.classifiers["type-a"] = Mock(spec=Classifier)
comp.classifiers["type-a"].predict_proba = lambda _: np.array( # type:ignore
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)
comp.classifiers[b"type-a"].predict_proba = lambda _: np.array( # type:ignore
[
[0.0, 1.0], # c1
[0.0, 0.9], # c2
[0.9, 0.1], # c3
]
)
comp.classifiers["type-b"] = Mock(spec=Classifier)
comp.classifiers["type-b"].predict_proba = lambda _: np.array( # type:ignore
comp.classifiers[b"type-b"] = Mock(spec=Classifier)
comp.classifiers[b"type-b"].predict_proba = lambda _: np.array( # type:ignore
[
[0.0, 1.0], # c4
]
)
pred = comp.sample_predict(sample)
assert pred == ["c1", "c2", "c4"]
assert pred == [b"c1", b"c2", b"c4"]
def test_fit_xy() -> None:
x = cast(
Dict[Hashable, np.ndarray],
Dict[ConstraintCategory, np.ndarray],
{
"type-a": np.array([[1.0, 1.0], [1.0, 2.0], [1.0, 3.0]]),
"type-b": np.array([[1.0, 4.0, 0.0]]),
b"type-a": np.array([[1.0, 1.0], [1.0, 2.0], [1.0, 3.0]]),
b"type-b": np.array([[1.0, 4.0, 0.0]]),
},
)
y = cast(
Dict[Hashable, np.ndarray],
Dict[ConstraintCategory, np.ndarray],
{
"type-a": np.array([[False, True], [False, True], [True, False]]),
"type-b": np.array([[False, True]]),
b"type-a": np.array([[False, True], [False, True], [True, False]]),
b"type-b": np.array([[False, True]]),
},
)
clf: Classifier = Mock(spec=Classifier)
@ -221,15 +207,15 @@ def test_fit_xy() -> None:
)
comp.fit_xy(x, y)
assert clf.clone.call_count == 2
clf_a = comp.classifiers["type-a"]
clf_b = comp.classifiers["type-b"]
clf_a = comp.classifiers[b"type-a"]
clf_b = comp.classifiers[b"type-b"]
assert clf_a.fit.call_count == 1 # type: ignore
assert clf_b.fit.call_count == 1 # type: ignore
assert_array_equal(clf_a.fit.call_args[0][0], x["type-a"]) # type: ignore
assert_array_equal(clf_b.fit.call_args[0][0], x["type-b"]) # type: ignore
assert_array_equal(clf_a.fit.call_args[0][0], x[b"type-a"]) # type: ignore
assert_array_equal(clf_b.fit.call_args[0][0], x[b"type-b"]) # type: ignore
assert thr.clone.call_count == 2
thr_a = comp.thresholds["type-a"]
thr_b = comp.thresholds["type-b"]
thr_a = comp.thresholds[b"type-a"]
thr_b = comp.thresholds[b"type-b"]
assert thr_a.fit.call_count == 1 # type: ignore
assert thr_b.fit.call_count == 1 # type: ignore
assert thr_a.fit.call_args[0][0] == clf_a # type: ignore
@ -238,12 +224,12 @@ def test_fit_xy() -> None:
def test_sample_xy(sample: Sample) -> None:
x_expected = {
"type-a": [[5.0, 1.0, 1.0], [5.0, 1.0, 2.0], [5.0, 1.0, 3.0]],
"type-b": [[5.0, 1.0, 4.0, 0.0]],
b"type-a": [[5.0, 1.0, 1.0, 0.0], [5.0, 1.0, 2.0, 0.0], [5.0, 1.0, 3.0, 0.0]],
b"type-b": [[5.0, 1.0, 4.0, 0.0]],
}
y_expected = {
"type-a": [[False, True], [False, True], [True, False]],
"type-b": [[False, True]],
b"type-a": [[False, True], [False, True], [True, False]],
b"type-b": [[False, True]],
}
xy = StaticLazyConstraintsComponent().sample_xy(None, sample)
assert xy is not None

@ -0,0 +1,456 @@
# 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 os
import sys
import time
from typing import Any
import numpy as np
import gurobipy as gp
from scipy.sparse import coo_matrix
from miplearn.features.extractor import FeaturesExtractor
from miplearn.features.sample import Hdf5Sample, MemorySample
from miplearn.instance.base import Instance
from miplearn.solvers.gurobi import GurobiSolver
from miplearn.solvers.internal import Variables, Constraints
from miplearn.solvers.tests import assert_equals
import cProfile
inf = float("inf")
def test_knapsack() -> None:
solver = GurobiSolver()
instance = solver.build_test_instance_knapsack()
model = instance.to_model()
solver.set_instance(instance, model)
extractor = FeaturesExtractor()
sample = MemorySample()
# after-load
# -------------------------------------------------------
extractor.extract_after_load_features(instance, solver, sample)
assert_equals(
sample.get_array("static_instance_features"),
np.array([67.0, 21.75]),
)
assert_equals(
sample.get_array("static_var_names"),
np.array(["x[0]", "x[1]", "x[2]", "x[3]", "z"], dtype="S"),
)
assert_equals(
sample.get_array("static_var_lower_bounds"),
np.array([0.0, 0.0, 0.0, 0.0, 0.0]),
)
assert_equals(
sample.get_array("static_var_obj_coeffs"),
np.array([505.0, 352.0, 458.0, 220.0, 0.0]),
)
assert_equals(
sample.get_array("static_var_types"),
np.array(["B", "B", "B", "B", "C"], dtype="S"),
)
assert_equals(
sample.get_array("static_var_upper_bounds"),
np.array([1.0, 1.0, 1.0, 1.0, 67.0]),
)
assert_equals(
sample.get_array("static_var_categories"),
np.array(["default", "default", "default", "default", ""], dtype="S"),
)
assert_equals(
sample.get_array("static_var_features"),
np.array(
[
[
23.0,
505.0,
1.0,
0.32899,
1e20,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
],
[
26.0,
352.0,
1.0,
0.229316,
1e20,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
],
[
20.0,
458.0,
1.0,
0.298371,
1e20,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
],
[
18.0,
220.0,
1.0,
0.143322,
1e20,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
],
[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
]
),
)
assert_equals(
sample.get_array("static_constr_names"),
np.array(["eq_capacity"], dtype="S"),
)
assert_equals(
sample.get_sparse("static_constr_lhs"),
[[23.0, 26.0, 20.0, 18.0, -1.0]],
)
assert_equals(
sample.get_array("static_constr_rhs"),
np.array([0.0]),
)
assert_equals(
sample.get_array("static_constr_senses"),
np.array(["="], dtype="S"),
)
assert_equals(
sample.get_array("static_constr_features"),
np.array([[0.0]]),
)
assert_equals(
sample.get_array("static_constr_categories"),
np.array(["eq_capacity"], dtype="S"),
)
assert_equals(
sample.get_array("static_constr_lazy"),
np.array([False]),
)
assert_equals(
sample.get_array("static_instance_features"),
np.array([67.0, 21.75]),
)
assert_equals(sample.get_scalar("static_constr_lazy_count"), 0)
# after-lp
# -------------------------------------------------------
lp_stats = solver.solve_lp()
extractor.extract_after_lp_features(solver, sample, lp_stats)
assert_equals(
sample.get_array("lp_var_basis_status"),
np.array(["U", "B", "U", "L", "U"], dtype="S"),
)
assert_equals(
sample.get_array("lp_var_reduced_costs"),
[193.615385, 0.0, 187.230769, -23.692308, 13.538462],
)
assert_equals(
sample.get_array("lp_var_sa_lb_down"),
[-inf, -inf, -inf, -0.111111, -inf],
)
assert_equals(
sample.get_array("lp_var_sa_lb_up"),
[1.0, 0.923077, 1.0, 1.0, 67.0],
)
assert_equals(
sample.get_array("lp_var_sa_obj_down"),
[311.384615, 317.777778, 270.769231, -inf, -13.538462],
)
assert_equals(
sample.get_array("lp_var_sa_obj_up"),
[inf, 570.869565, inf, 243.692308, inf],
)
assert_equals(
sample.get_array("lp_var_sa_ub_down"),
np.array([0.913043, 0.923077, 0.9, 0.0, 43.0]),
)
assert_equals(
sample.get_array("lp_var_sa_ub_up"),
np.array([2.043478, inf, 2.2, inf, 69.0]),
)
assert_equals(
sample.get_array("lp_var_values"),
np.array([1.0, 0.923077, 1.0, 0.0, 67.0]),
)
assert_equals(
sample.get_array("lp_var_features"),
np.array(
[
[
23.0,
505.0,
1.0,
0.32899,
1e20,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
1.0,
1.0,
5.265874,
0.0,
193.615385,
-0.111111,
1.0,
311.384615,
570.869565,
0.913043,
2.043478,
1.0,
],
[
26.0,
352.0,
1.0,
0.229316,
1e20,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.076923,
1.0,
1.0,
3.532875,
0.0,
0.0,
-0.111111,
0.923077,
317.777778,
570.869565,
0.923077,
69.0,
0.923077,
],
[
20.0,
458.0,
1.0,
0.298371,
1e20,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
1.0,
1.0,
5.232342,
0.0,
187.230769,
-0.111111,
1.0,
270.769231,
570.869565,
0.9,
2.2,
1.0,
],
[
18.0,
220.0,
1.0,
0.143322,
1e20,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
1.0,
-1.0,
5.265874,
0.0,
-23.692308,
-0.111111,
1.0,
-13.538462,
243.692308,
0.0,
69.0,
0.0,
],
[
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
1.0,
-1.0,
5.265874,
0.0,
13.538462,
-0.111111,
67.0,
-13.538462,
570.869565,
43.0,
69.0,
67.0,
],
]
),
)
assert_equals(
sample.get_array("lp_constr_basis_status"),
np.array(["N"], dtype="S"),
)
assert_equals(
sample.get_array("lp_constr_dual_values"),
np.array([13.538462]),
)
assert_equals(
sample.get_array("lp_constr_sa_rhs_down"),
np.array([-24.0]),
)
assert_equals(
sample.get_array("lp_constr_sa_rhs_up"),
np.array([2.0]),
)
assert_equals(
sample.get_array("lp_constr_slacks"),
np.array([0.0]),
)
assert_equals(
sample.get_array("lp_constr_features"),
np.array([[0.0, 13.538462, -24.0, 2.0, 0.0]]),
)
# after-mip
# -------------------------------------------------------
solver.solve()
extractor.extract_after_mip_features(solver, sample)
assert_equals(
sample.get_array("mip_var_values"), np.array([1.0, 0.0, 1.0, 1.0, 61.0])
)
assert_equals(sample.get_array("mip_constr_slacks"), np.array([0.0]))
def test_constraint_getindex() -> None:
cf = Constraints(
names=np.array(["c1", "c2", "c3"], dtype="S"),
rhs=np.array([1.0, 2.0, 3.0]),
senses=np.array(["=", "<", ">"], dtype="S"),
lhs=coo_matrix(
[
[1, 2, 3],
[4, 5, 6],
[7, 8, 9],
]
),
)
assert_equals(
cf[[True, False, True]],
Constraints(
names=np.array(["c1", "c3"], dtype="S"),
rhs=np.array([1.0, 3.0]),
senses=np.array(["=", ">"], dtype="S"),
lhs=coo_matrix(
[
[1, 2, 3],
[7, 8, 9],
]
),
),
)
def test_assert_equals() -> None:
assert_equals("hello", "hello")
assert_equals([1.0, 2.0], [1.0, 2.0])
assert_equals(np.array([1.0, 2.0]), np.array([1.0, 2.0]))
assert_equals(
np.array([[1.0, 2.0], [3.0, 4.0]]),
np.array([[1.0, 2.0], [3.0, 4.0]]),
)
assert_equals(
Variables(values=np.array([1.0, 2.0])), # type: ignore
Variables(values=np.array([1.0, 2.0])), # type: ignore
)
assert_equals(np.array([True, True]), [True, True])
assert_equals((1.0,), (1.0,))
assert_equals({"x": 10}, {"x": 10})
class MpsInstance(Instance):
def __init__(self, filename: str) -> None:
super().__init__()
self.filename = filename
def to_model(self) -> Any:
return gp.read(self.filename)
def main() -> None:
solver = GurobiSolver()
instance = MpsInstance(sys.argv[1])
solver.set_instance(instance)
extractor = FeaturesExtractor(with_lhs=False)
sample = Hdf5Sample("tmp/prof.h5", mode="w")
extractor.extract_after_load_features(instance, solver, sample)
lp_stats = solver.solve_lp(tee=True)
extractor.extract_after_lp_features(solver, sample, lp_stats)
if __name__ == "__main__":
cProfile.run("main()", filename="tmp/prof")
os.system("flameprof tmp/prof > tmp/prof.svg")

@ -0,0 +1,71 @@
# 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 tempfile import NamedTemporaryFile
from typing import Any
import numpy as np
from scipy.sparse import coo_matrix
from miplearn.features.sample import MemorySample, Sample, Hdf5Sample
def test_memory_sample() -> None:
_test_sample(MemorySample())
def test_hdf5_sample() -> None:
file = NamedTemporaryFile()
_test_sample(Hdf5Sample(file.name))
def _test_sample(sample: Sample) -> None:
_assert_roundtrip_scalar(sample, "A")
_assert_roundtrip_scalar(sample, True)
_assert_roundtrip_scalar(sample, 1)
_assert_roundtrip_scalar(sample, 1.0)
assert sample.get_scalar("unknown-key") is None
_assert_roundtrip_array(sample, np.array([True, False]))
_assert_roundtrip_array(sample, np.array([1, 2, 3]))
_assert_roundtrip_array(sample, np.array([1.0, 2.0, 3.0]))
_assert_roundtrip_array(sample, np.array(["A", "BB", "CCC"], dtype="S"))
assert sample.get_array("unknown-key") is None
_assert_roundtrip_sparse(
sample,
coo_matrix(
[
[1.0, 0.0, 0.0],
[0.0, 2.0, 3.0],
[0.0, 0.0, 4.0],
],
),
)
assert sample.get_sparse("unknown-key") is None
def _assert_roundtrip_array(sample: Sample, original: np.ndarray) -> None:
sample.put_array("key", original)
recovered = sample.get_array("key")
assert recovered is not None
assert isinstance(recovered, np.ndarray)
assert (recovered == original).all()
def _assert_roundtrip_scalar(sample: Sample, original: Any) -> None:
sample.put_scalar("key", original)
recovered = sample.get_scalar("key")
assert recovered == original
assert recovered is not None
assert isinstance(
recovered, original.__class__
), f"Expected {original.__class__}, found {recovered.__class__} instead"
def _assert_roundtrip_sparse(sample: Sample, original: coo_matrix) -> None:
sample.put_sparse("key", original)
recovered = sample.get_sparse("key")
assert recovered is not None
assert isinstance(recovered, coo_matrix)
assert (original != recovered).sum() == 0

@ -0,0 +1,32 @@
# 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 miplearn.solvers.learning import LearningSolver
from miplearn.solvers.gurobi import GurobiSolver
from miplearn.features.sample import Hdf5Sample
from miplearn.instance.file import FileInstance
def test_usage() -> None:
# Create original instance
original = GurobiSolver().build_test_instance_knapsack()
# Save instance to disk
filename = tempfile.mktemp()
FileInstance.save(original, filename)
sample = Hdf5Sample(filename)
assert len(sample.get_array("pickled")) > 0
# Solve instance from disk
solver = LearningSolver(solver=GurobiSolver())
solver.solve(FileInstance(filename))
# Assert HDF5 contains training data
sample = FileInstance(filename).get_samples()[0]
assert sample.get_scalar("mip_lower_bound") == 1183.0
assert sample.get_scalar("mip_upper_bound") == 1183.0
assert len(sample.get_array("lp_var_values")) == 5
assert len(sample.get_array("mip_var_values")) == 5

@ -9,6 +9,7 @@ from scipy.stats import uniform, randint
from miplearn.problems.tsp import TravelingSalesmanGenerator, TravelingSalesmanInstance
from miplearn.solvers.learning import LearningSolver
from miplearn.solvers.tests import assert_equals
def test_generator() -> None:
@ -41,14 +42,9 @@ def test_instance() -> None:
solver.solve(instance)
assert len(instance.get_samples()) == 1
sample = instance.get_samples()[0]
assert sample.after_mip is not None
features = sample.after_mip
assert features is not None
assert features.variables is not None
assert features.variables.values == [1.0, 0.0, 1.0, 1.0, 0.0, 1.0]
assert features.mip_solve is not None
assert features.mip_solve.mip_lower_bound == 4.0
assert features.mip_solve.mip_upper_bound == 4.0
assert_equals(sample.get_array("mip_var_values"), [1.0, 0.0, 1.0, 1.0, 0.0, 1.0])
assert sample.get_scalar("mip_lower_bound") == 4.0
assert sample.get_scalar("mip_upper_bound") == 4.0
def test_subtour() -> None:
@ -67,32 +63,31 @@ def test_subtour() -> None:
instance = TravelingSalesmanInstance(n_cities, distances)
solver = LearningSolver()
solver.solve(instance)
assert len(instance.get_samples()) == 1
sample = instance.get_samples()[0]
assert sample.after_mip is not None
features = sample.after_mip
assert features.extra is not None
assert "lazy_enforced" in features.extra
lazy_enforced = features.extra["lazy_enforced"]
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
assert features.variables is not None
assert features.variables.values == [
1.0,
0.0,
0.0,
1.0,
0.0,
1.0,
0.0,
0.0,
0.0,
1.0,
0.0,
0.0,
0.0,
1.0,
1.0,
]
assert_equals(
sample.get_array("mip_var_values"),
[
1.0,
0.0,
0.0,
1.0,
0.0,
1.0,
0.0,
0.0,
0.0,
1.0,
0.0,
0.0,
0.0,
1.0,
1.0,
],
)
solver.fit([instance])
solver.solve(instance)

@ -38,25 +38,22 @@ def test_learning_solver(
assert len(instance.get_samples()) > 0
sample = instance.get_samples()[0]
after_mip = sample.after_mip
assert after_mip is not None
assert after_mip.variables is not None
assert after_mip.variables.values == [1.0, 0.0, 1.0, 1.0, 61.0]
assert after_mip.mip_solve is not None
assert after_mip.mip_solve.mip_lower_bound == 1183.0
assert after_mip.mip_solve.mip_upper_bound == 1183.0
assert after_mip.mip_solve.mip_log is not None
assert len(after_mip.mip_solve.mip_log) > 100
after_lp = sample.after_lp
assert after_lp is not None
assert after_lp.variables is not None
assert_equals(after_lp.variables.values, [1.0, 0.923077, 1.0, 0.0, 67.0])
assert after_lp.lp_solve is not None
assert after_lp.lp_solve.lp_value is not None
assert round(after_lp.lp_solve.lp_value, 3) == 1287.923
assert after_lp.lp_solve.lp_log is not None
assert len(after_lp.lp_solve.lp_log) > 100
assert_equals(
sample.get_array("mip_var_values"), [1.0, 0.0, 1.0, 1.0, 61.0]
)
assert sample.get_scalar("mip_lower_bound") == 1183.0
assert sample.get_scalar("mip_upper_bound") == 1183.0
mip_log = sample.get_scalar("mip_log")
assert mip_log is not None
assert len(mip_log) > 100
assert_equals(
sample.get_array("lp_var_values"), [1.0, 0.923077, 1.0, 0.0, 67.0]
)
assert_equals(sample.get_scalar("lp_value"), 1287.923077)
lp_log = sample.get_scalar("lp_log")
assert lp_log is not None
assert len(lp_log) > 100
solver.fit([instance], n_jobs=4)
solver.solve(instance)

@ -1,158 +0,0 @@
# 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 numpy as np
from miplearn.features import (
FeaturesExtractor,
InstanceFeatures,
VariableFeatures,
ConstraintFeatures,
)
from miplearn.solvers.gurobi import GurobiSolver
from miplearn.solvers.tests import assert_equals
inf = float("inf")
def test_knapsack() -> None:
solver = GurobiSolver()
instance = solver.build_test_instance_knapsack()
model = instance.to_model()
solver.set_instance(instance, model)
solver.solve_lp()
features = FeaturesExtractor().extract(instance, solver)
assert features.variables is not None
assert features.instance is not None
assert_equals(
features.variables,
VariableFeatures(
names=["x[0]", "x[1]", "x[2]", "x[3]", "z"],
basis_status=["U", "B", "U", "L", "U"],
categories=["default", "default", "default", "default", None],
lower_bounds=[0.0, 0.0, 0.0, 0.0, 0.0],
obj_coeffs=[505.0, 352.0, 458.0, 220.0, 0.0],
reduced_costs=[193.615385, 0.0, 187.230769, -23.692308, 13.538462],
sa_lb_down=[-inf, -inf, -inf, -0.111111, -inf],
sa_lb_up=[1.0, 0.923077, 1.0, 1.0, 67.0],
sa_obj_down=[311.384615, 317.777778, 270.769231, -inf, -13.538462],
sa_obj_up=[inf, 570.869565, inf, 243.692308, inf],
sa_ub_down=[0.913043, 0.923077, 0.9, 0.0, 43.0],
sa_ub_up=[2.043478, inf, 2.2, inf, 69.0],
types=["B", "B", "B", "B", "C"],
upper_bounds=[1.0, 1.0, 1.0, 1.0, 67.0],
user_features=[
[23.0, 505.0],
[26.0, 352.0],
[20.0, 458.0],
[18.0, 220.0],
None,
],
values=[1.0, 0.923077, 1.0, 0.0, 67.0],
alvarez_2017=[
[1.0, 0.32899, 0.0, 0.0, 1.0, 1.0, 5.265874, 46.051702],
[1.0, 0.229316, 0.0, 0.076923, 1.0, 1.0, 3.532875, 5.388476],
[1.0, 0.298371, 0.0, 0.0, 1.0, 1.0, 5.232342, 46.051702],
[1.0, 0.143322, 0.0, 0.0, 1.0, -1.0, 46.051702, 3.16515],
[0.0, 0.0, 0.0, 0.0, 1.0, -1.0, 0.0, 0.0],
],
),
)
assert_equals(
features.constraints,
ConstraintFeatures(
basis_status=["N"],
categories=["eq_capacity"],
dual_values=[13.538462],
names=["eq_capacity"],
lazy=[False],
lhs=[
[
("x[0]", 23.0),
("x[1]", 26.0),
("x[2]", 20.0),
("x[3]", 18.0),
("z", -1.0),
],
],
rhs=[0.0],
sa_rhs_down=[-24.0],
sa_rhs_up=[2.0],
senses=["="],
slacks=[0.0],
user_features=[None],
),
)
assert_equals(
features.instance,
InstanceFeatures(
user_features=[67.0, 21.75],
lazy_constraint_count=0,
),
)
def test_constraint_getindex() -> None:
cf = ConstraintFeatures(
names=["c1", "c2", "c3"],
rhs=[1.0, 2.0, 3.0],
senses=["=", "<", ">"],
lhs=[
[
("x1", 1.0),
("x2", 1.0),
],
[
("x2", 2.0),
("x3", 2.0),
],
[
("x3", 3.0),
("x4", 3.0),
],
],
)
assert_equals(
cf[[True, False, True]],
ConstraintFeatures(
names=["c1", "c3"],
rhs=[1.0, 3.0],
senses=["=", ">"],
lhs=[
[
("x1", 1.0),
("x2", 1.0),
],
[
("x3", 3.0),
("x4", 3.0),
],
],
),
)
def test_assert_equals() -> None:
assert_equals("hello", "hello")
assert_equals([1.0, 2.0], [1.0, 2.0])
assert_equals(
np.array([1.0, 2.0]),
np.array([1.0, 2.0]),
)
assert_equals(
np.array([[1.0, 2.0], [3.0, 4.0]]),
np.array([[1.0, 2.0], [3.0, 4.0]]),
)
assert_equals(
VariableFeatures(values=np.array([1.0, 2.0])), # type: ignore
VariableFeatures(values=np.array([1.0, 2.0])), # type: ignore
)
assert_equals(
np.array([True, True]),
[True, True],
)
assert_equals((1.0,), (1.0,))
assert_equals({"x": 10}, {"x": 10})
Loading…
Cancel
Save