diff --git a/miplearn/__init__.py b/miplearn/__init__.py index 2a83ff8..ba8d728 100644 --- a/miplearn/__init__.py +++ b/miplearn/__init__.py @@ -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 diff --git a/miplearn/components/__init__.py b/miplearn/components/__init__.py index 8e1c524..b5dde38 100644 --- a/miplearn/components/__init__.py +++ b/miplearn/components/__init__.py @@ -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 diff --git a/miplearn/components/lazy_static.py b/miplearn/components/lazy_static.py index d223eaf..b685fd4 100644 --- a/miplearn/components/lazy_static.py +++ b/miplearn/components/lazy_static.py @@ -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): diff --git a/miplearn/components/relaxation.py b/miplearn/components/relaxation.py new file mode 100644 index 0000000..9320ad2 --- /dev/null +++ b/miplearn/components/relaxation.py @@ -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) + + diff --git a/miplearn/components/tests/test_lazy_static.py b/miplearn/components/tests/test_lazy_static.py index 56a0014..5bfa76e 100644 --- a/miplearn/components/tests/test_lazy_static.py +++ b/miplearn/components/tests/test_lazy_static.py @@ -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], diff --git a/miplearn/components/tests/test_relaxation.py b/miplearn/components/tests/test_relaxation.py new file mode 100644 index 0000000..f75bc68 --- /dev/null +++ b/miplearn/components/tests/test_relaxation.py @@ -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 + + + + diff --git a/miplearn/instance.py b/miplearn/instance.py index 10157b3..c37b751 100644 --- a/miplearn/instance.py +++ b/miplearn/instance.py @@ -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. diff --git a/miplearn/solvers/gurobi.py b/miplearn/solvers/gurobi.py index 822ae7a..21c6921 100644 --- a/miplearn/solvers/gurobi.py +++ b/miplearn/solvers/gurobi.py @@ -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") diff --git a/miplearn/solvers/internal.py b/miplearn/solvers/internal.py index 9c9ee4a..c42d524 100644 --- a/miplearn/solvers/internal.py +++ b/miplearn/solvers/internal.py @@ -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 diff --git a/miplearn/solvers/learning.py b/miplearn/solvers/learning.py index 958699e..98bbac9 100644 --- a/miplearn/solvers/learning.py +++ b/miplearn/solvers/learning.py @@ -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 diff --git a/miplearn/solvers/pyomo/base.py b/miplearn/solvers/pyomo/base.py index b509985..472019c 100644 --- a/miplearn/solvers/pyomo/base.py +++ b/miplearn/solvers/pyomo/base.py @@ -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") \ No newline at end of file