Implement RelaxationComponent

pull/3/head
Alinson S. Xavier 5 years ago
parent 3c4045f64b
commit 1b6982ae8d

@ -13,6 +13,7 @@ from .components.lazy_dynamic import DynamicLazyConstraintsComponent
from .components.lazy_static import StaticLazyConstraintsComponent
from .components.cuts import UserCutsComponent
from .components.primal import PrimalSolutionComponent
from .components.relaxation import RelaxationComponent
from .classifiers.adaptive import AdaptiveClassifier
from .classifiers.threshold import MinPrecisionThreshold

@ -29,7 +29,6 @@ def classifier_evaluation_dict(tp, tn, fp, fn):
else:
d["Precision"] = 1.0
t = (p + n) / 100.0
d["Predicted positive (%)"] = d["Predicted positive"] / t
d["Predicted negative (%)"] = d["Predicted negative"] / t

@ -25,7 +25,7 @@ class StaticLazyConstraintsComponent(Component):
use_two_phase_gap=True,
large_gap=1e-2,
violation_tolerance=-0.5,
):
):
self.threshold = threshold
self.classifier_prototype = classifier
self.classifiers = {}
@ -116,11 +116,11 @@ class StaticLazyConstraintsComponent(Component):
logger.info("Extracting lazy constraints...")
for cid in solver.internal_solver.get_constraint_ids():
if instance.is_constraint_lazy(cid):
category = instance.get_lazy_constraint_category(cid)
category = instance.get_constraint_category(cid)
if category not in x:
x[category] = []
constraints[category] = []
x[category] += [instance.get_lazy_constraint_features(cid)]
x[category] += [instance.get_constraint_features(cid)]
c = LazyConstraint(cid=cid,
obj=solver.internal_solver.extract_constraint(cid))
constraints[category] += [c]
@ -147,7 +147,7 @@ class StaticLazyConstraintsComponent(Component):
constraints = {}
for instance in train_instances:
for cid in instance.found_violated_lazy_constraints:
category = instance.get_lazy_constraint_category(cid)
category = instance.get_constraint_category(cid)
if category not in constraints:
constraints[category] = set()
constraints[category].add(cid)
@ -162,7 +162,7 @@ class StaticLazyConstraintsComponent(Component):
result[category] = []
for instance in train_instances:
for cid in cids:
result[category].append(instance.get_lazy_constraint_features(cid))
result[category].append(instance.get_constraint_features(cid))
return result
def y(self, train_instances):

@ -0,0 +1,151 @@
# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization
# Copyright (C) 2020, UChicago Argonne, LLC. All rights reserved.
# Released under the modified BSD license. See COPYING.md for more details.
import logging
import sys
from copy import deepcopy
import numpy as np
from miplearn.components import classifier_evaluation_dict
from tqdm import tqdm
from miplearn import Component
from miplearn.classifiers.counting import CountingClassifier
logger = logging.getLogger(__name__)
class RelaxationComponent(Component):
"""
A Component which builds a relaxation of the problem by dropping constraints.
Currently, this component drops all integrality constraints, as well as
all inequality constraints which are not likely binding in the LP relaxation.
In a future version of MIPLearn, this component may decide to keep some
integrality constraints it it determines that they have small impact on
running time, but large impact on dual bound.
"""
def __init__(self,
classifier=CountingClassifier(),
threshold=0.95,
slack_tolerance=1e-5,
):
self.classifiers = {}
self.classifier_prototype = classifier
self.threshold = threshold
self.slack_tolerance = slack_tolerance
def before_solve(self, solver, instance, _):
logger.info("Relaxing integrality...")
solver.internal_solver.relax()
logger.info("Predicting redundant LP constraints...")
cids = solver.internal_solver.get_constraint_ids()
x, constraints = self.x([instance],
constraint_ids=cids,
return_constraints=True)
y = self.predict(x)
n_removed = 0
for category in y.keys():
for i in range(len(y[category])):
if y[category][i][0] == 1:
cid = constraints[category][i]
solver.internal_solver.extract_constraint(cid)
n_removed += 1
logger.info("Removed %d predicted redundant LP constraints" % n_removed)
def after_solve(self, solver, instance, model, results):
instance.slacks = solver.internal_solver.get_constraint_slacks()
def fit(self, training_instances):
training_instances = [instance
for instance in training_instances
if hasattr(instance, "slacks")]
logger.debug("Extracting x and y...")
x = self.x(training_instances)
y = self.y(training_instances)
logger.debug("Fitting...")
for category in tqdm(x.keys(),
desc="Fit (relaxation)",
disable=not sys.stdout.isatty()):
if category not in self.classifiers:
self.classifiers[category] = deepcopy(self.classifier_prototype)
self.classifiers[category].fit(x[category], y[category])
def x(self,
instances,
constraint_ids=None,
return_constraints=False):
x = {}
constraints = {}
for instance in instances:
if constraint_ids is not None:
cids = constraint_ids
else:
cids = instance.slacks.keys()
for cid in cids:
category = instance.get_constraint_category(cid)
if category is None:
continue
if category not in x:
x[category] = []
constraints[category] = []
x[category] += [instance.get_constraint_features(cid)]
constraints[category] += [cid]
if return_constraints:
return x, constraints
else:
return x
def y(self, instances):
y = {}
for instance in instances:
for (cid, slack) in instance.slacks.items():
category = instance.get_constraint_category(cid)
if category is None:
continue
if category not in y:
y[category] = []
if slack > self.slack_tolerance:
y[category] += [[1]]
else:
y[category] += [[0]]
return y
def predict(self, x):
y = {}
for (category, x_cat) in x.items():
if category not in self.classifiers:
continue
y[category] = []
#x_cat = np.array(x_cat)
proba = self.classifiers[category].predict_proba(x_cat)
for i in range(len(proba)):
if proba[i][1] >= self.threshold:
y[category] += [[1]]
else:
y[category] += [[0]]
return y
def evaluate(self, instance):
x = self.x([instance])
y_true = self.y([instance])
y_pred = self.predict(x)
tp, tn, fp, fn = 0, 0, 0, 0
for category in y_true.keys():
for i in range(len(y_true[category])):
if y_pred[category][i][0] == 1:
if y_true[category][i][0] == 1:
tp += 1
else:
fp += 1
else:
if y_true[category][i][0] == 1:
fn += 1
else:
tn += 1
return classifier_evaluation_dict(tp, tn, fp, fn)

@ -29,12 +29,12 @@ def test_usage_with_solver():
"c3": True,
"c4": True,
}[cid])
instance.get_lazy_constraint_features = Mock(side_effect=lambda cid: {
instance.get_constraint_features = Mock(side_effect=lambda cid: {
"c2": [1.0, 0.0],
"c3": [0.5, 0.5],
"c4": [1.0],
}[cid])
instance.get_lazy_constraint_category = Mock(side_effect=lambda cid: {
instance.get_constraint_category = Mock(side_effect=lambda cid: {
"c2": "type-a",
"c3": "type-a",
"c4": "type-b",
@ -72,13 +72,13 @@ def test_usage_with_solver():
])
# For the lazy ones, should ask for features
instance.get_lazy_constraint_features.assert_has_calls([
instance.get_constraint_features.assert_has_calls([
call("c2"), call("c3"), call("c4"),
])
# Should also ask for categories
assert instance.get_lazy_constraint_category.call_count == 3
instance.get_lazy_constraint_category.assert_has_calls([
assert instance.get_constraint_category.call_count == 3
instance.get_constraint_category.assert_has_calls([
call("c2"), call("c3"), call("c4"),
])
@ -126,14 +126,14 @@ def test_usage_with_solver():
def test_fit():
instance_1 = Mock(spec=Instance)
instance_1.found_violated_lazy_constraints = ["c1", "c2", "c4", "c5"]
instance_1.get_lazy_constraint_category = Mock(side_effect=lambda cid: {
instance_1.get_constraint_category = Mock(side_effect=lambda cid: {
"c1": "type-a",
"c2": "type-a",
"c3": "type-a",
"c4": "type-b",
"c5": "type-b",
}[cid])
instance_1.get_lazy_constraint_features = Mock(side_effect=lambda cid: {
instance_1.get_constraint_features = Mock(side_effect=lambda cid: {
"c1": [1, 1],
"c2": [1, 2],
"c3": [1, 3],
@ -143,14 +143,14 @@ def test_fit():
instance_2 = Mock(spec=Instance)
instance_2.found_violated_lazy_constraints = ["c2", "c3", "c4"]
instance_2.get_lazy_constraint_category = Mock(side_effect=lambda cid: {
instance_2.get_constraint_category = Mock(side_effect=lambda cid: {
"c1": "type-a",
"c2": "type-a",
"c3": "type-a",
"c4": "type-b",
"c5": "type-b",
}[cid])
instance_2.get_lazy_constraint_features = Mock(side_effect=lambda cid: {
instance_2.get_constraint_features = Mock(side_effect=lambda cid: {
"c1": [2, 1],
"c2": [2, 2],
"c3": [2, 3],

@ -0,0 +1,188 @@
# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization
# Copyright (C) 2020, UChicago Argonne, LLC. All rights reserved.
# Released under the modified BSD license. See COPYING.md for more details.
from unittest.mock import Mock, call
from miplearn import (RelaxationComponent,
LearningSolver,
Instance,
InternalSolver)
from miplearn.classifiers import Classifier
def test_usage_with_solver():
solver = Mock(spec=LearningSolver)
internal = solver.internal_solver = Mock(spec=InternalSolver)
internal.get_constraint_ids = Mock(return_value=["c1", "c2", "c3", "c4"])
internal.get_constraint_slacks = Mock(side_effect=lambda: {
"c1": 0.5,
"c2": 0.0,
"c3": 0.0,
"c4": 1.4,
})
instance = Mock(spec=Instance)
instance.get_constraint_features = Mock(side_effect=lambda cid: {
"c2": [1.0, 0.0],
"c3": [0.5, 0.5],
"c4": [1.0],
}[cid])
instance.get_constraint_category = Mock(side_effect=lambda cid: {
"c1": None,
"c2": "type-a",
"c3": "type-a",
"c4": "type-b",
}[cid])
component = RelaxationComponent()
component.classifiers = {
"type-a": Mock(spec=Classifier),
"type-b": Mock(spec=Classifier),
}
component.classifiers["type-a"].predict_proba = \
Mock(return_value=[
[0.20, 0.80],
[0.05, 0.95],
])
component.classifiers["type-b"].predict_proba = \
Mock(return_value=[
[0.02, 0.98],
])
# LearningSolver calls before_solve
component.before_solve(solver, instance, None)
# Should relax integrality of the problem
internal.relax.assert_called_once()
# Should query list of constraints
internal.get_constraint_ids.assert_called_once()
# Should query category and features for each constraint in the model
assert instance.get_constraint_category.call_count == 4
instance.get_constraint_category.assert_has_calls([
call("c1"), call("c2"), call("c3"), call("c4"),
])
# For constraint with non-null categories, should ask for features
assert instance.get_constraint_features.call_count == 3
instance.get_constraint_features.assert_has_calls([
call("c2"), call("c3"), call("c4"),
])
# Should ask ML to predict whether constraint should be removed
component.classifiers["type-a"].predict_proba.assert_called_once_with([[1.0, 0.0], [0.5, 0.5]])
component.classifiers["type-b"].predict_proba.assert_called_once_with([[1.0]])
# Should ask internal solver to remove constraints predicted as redundant
assert internal.extract_constraint.call_count == 2
internal.extract_constraint.assert_has_calls([
call("c3"), call("c4"),
])
# LearningSolver calls after_solve
component.after_solve(solver, instance, None, None)
# Should query slack for all constraints
internal.get_constraint_slacks.assert_called_once()
# Should store constraint slacks in instance object
assert hasattr(instance, "slacks")
assert instance.slacks == {
"c1": 0.5,
"c2": 0.0,
"c3": 0.0,
"c4": 1.4,
}
def test_x_y_fit_predict_evaluate():
instances = [Mock(spec=Instance), Mock(spec=Instance)]
component = RelaxationComponent(slack_tolerance=0.05,
threshold=0.80)
component.classifiers = {
"type-a": Mock(spec=Classifier),
"type-b": Mock(spec=Classifier),
}
component.classifiers["type-a"].predict_proba = \
Mock(return_value=[
[0.20, 0.80],
])
component.classifiers["type-b"].predict_proba = \
Mock(return_value=[
[0.50, 0.50],
[0.05, 0.95],
])
# First mock instance
instances[0].slacks = {
"c1": 0.00,
"c2": 0.05,
"c3": 0.00,
"c4": 30.0,
}
instances[0].get_constraint_category = Mock(side_effect=lambda cid: {
"c1": None,
"c2": "type-a",
"c3": "type-a",
"c4": "type-b",
}[cid])
instances[0].get_constraint_features = Mock(side_effect=lambda cid: {
"c2": [1.0, 0.0],
"c3": [0.5, 0.5],
"c4": [1.0],
}[cid])
# Second mock instance
instances[1].slacks = {
"c1": 0.00,
"c3": 0.30,
"c4": 0.00,
"c5": 0.00,
}
instances[1].get_constraint_category = Mock(side_effect=lambda cid: {
"c1": None,
"c3": "type-a",
"c4": "type-b",
"c5": "type-b",
}[cid])
instances[1].get_constraint_features = Mock(side_effect=lambda cid: {
"c3": [0.3, 0.4],
"c4": [0.7],
"c5": [0.8],
}[cid])
expected_x = {
"type-a": [[1.0, 0.0], [0.5, 0.5], [0.3, 0.4]],
"type-b": [[1.0], [0.7], [0.8]],
}
expected_y = {
"type-a": [[0], [0], [1]],
"type-b": [[1], [0], [0]]
}
# Should build X and Y matrices correctly
assert component.x(instances) == expected_x
assert component.y(instances) == expected_y
# Should pass along X and Y matrices to classifiers
component.fit(instances)
component.classifiers["type-a"].fit.assert_called_with(expected_x["type-a"], expected_y["type-a"])
component.classifiers["type-b"].fit.assert_called_with(expected_x["type-b"], expected_y["type-b"])
assert component.predict(expected_x) == {
"type-a": [[1]],
"type-b": [[0], [1]]
}
ev = component.evaluate(instances[1])
assert ev["True positive"] == 1
assert ev["True negative"] == 1
assert ev["False positive"] == 1
assert ev["False negative"] == 0

@ -82,6 +82,12 @@ class Instance(ABC):
"""
return "default"
def get_constraint_features(self, cid):
return np.zeros(1)
def get_constraint_category(self, cid):
return cid
def has_static_lazy_constraints(self):
return False
@ -91,12 +97,6 @@ class Instance(ABC):
def is_constraint_lazy(self, cid):
return False
def get_lazy_constraint_features(self, cid):
return np.zeros(1)
def get_lazy_constraint_category(self, cid):
return cid
def find_violated_lazy_constraints(self, model):
"""
Returns lazy constraint violations found for the current solution.

@ -274,6 +274,13 @@ class GurobiSolver(InternalSolver):
else:
raise Exception("Unknown sense: %s" % sense)
def get_constraint_slacks(self):
return {c.ConstrName: c.Slack for c in self.model.getConstrs()}
def relax(self):
self.model = self.model.relax()
self._update_vars()
def set_branching_priorities(self, priorities):
self._raise_if_callback()
logger.warning("set_branching_priorities not implemented")

@ -176,6 +176,21 @@ class InternalSolver(ABC):
"""
pass
@abstractmethod
def relax(self):
"""
Drops all integrality constraints from the model.
"""
pass
@abstractmethod
def get_constraint_slacks(self):
"""
Returns a dictionary mapping constraint name to the constraint slack
in the current solution.
"""
pass
@abstractmethod
def is_constraint_satisfied(self, cobj):
pass

@ -25,13 +25,20 @@ INSTANCES = [None] # type: List[Optional[dict]]
def _parallel_solve(instance_idx):
solver = deepcopy(SOLVER[0])
instance = INSTANCES[0][instance_idx]
results = solver.solve(instance)
if not hasattr(instance, "found_violated_lazy_constraints"):
instance.found_violated_lazy_constraints = []
if not hasattr(instance, "found_violated_user_cuts"):
instance.found_violated_user_cuts = []
if not hasattr(instance, "slacks"):
instance.slacks = {}
solver_results = solver.solve(instance)
return {
"Results": results,
"Solution": instance.solution,
"LP solution": instance.lp_solution,
"Violated lazy constraints": instance.found_violated_lazy_constraints,
#"Violated user cuts": instance.found_violated_user_cuts,
"solver_results": solver_results,
"solution": instance.solution,
"lp_solution": instance.lp_solution,
"found_violated_lazy_constraints": instance.found_violated_lazy_constraints,
"found_violated_user_cuts": instance.found_violated_user_cuts,
"slacks": instance.slacks
}
@ -245,16 +252,17 @@ class LearningSolver:
list(range(len(instances))),
num_cpus=n_jobs,
desc=label)
results = [p["Results"] for p in p_map_results]
results = [p["solver_results"] for p in p_map_results]
for (idx, r) in enumerate(p_map_results):
instances[idx].solution = r["Solution"]
instances[idx].lp_solution = r["LP solution"]
instances[idx].lp_value = r["Results"]["LP value"]
instances[idx].lower_bound = r["Results"]["Lower bound"]
instances[idx].upper_bound = r["Results"]["Upper bound"]
instances[idx].found_violated_lazy_constraints = r["Violated lazy constraints"]
#instances[idx].found_violated_user_cuts = r["Violated user cuts"]
instances[idx].solver_log = r["Results"]["Log"]
instances[idx].solution = r["solution"]
instances[idx].lp_solution = r["lp_solution"]
instances[idx].lp_value = r["solver_results"]["LP value"]
instances[idx].lower_bound = r["solver_results"]["Lower bound"]
instances[idx].upper_bound = r["solver_results"]["Upper bound"]
instances[idx].found_violated_lazy_constraints = r["found_violated_lazy_constraints"]
instances[idx].found_violated_user_cuts = r["found_violated_user_cuts"]
instances[idx].slacks = r["slacks"]
instances[idx].solver_log = r["solver_results"]["Log"]
self._restore_miplearn_logger()
return results

@ -244,3 +244,9 @@ class BasePyomoSolver(InternalSolver):
@abstractmethod
def _get_gap_tolerance_option_name(self):
pass
def relax(self):
raise Exception("not implemented")
def get_constraint_slacks(self):
raise Exception("not implemented")
Loading…
Cancel
Save