From 191da25cfc268cd1fd89d490226025b1df16b51e Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Thu, 7 Jan 2021 10:01:04 -0600 Subject: [PATCH] Split relaxation.py into multiple files --- miplearn/components/relaxation.py | 313 +----------------- miplearn/components/steps/__init__.py | 0 miplearn/components/steps/convert_tight.py | 153 +++++++++ miplearn/components/steps/drop_redundant.py | 186 +++++++++++ .../components/steps/relax_integrality.py | 19 ++ miplearn/components/tests/test_relaxation.py | 2 +- 6 files changed, 366 insertions(+), 307 deletions(-) create mode 100644 miplearn/components/steps/__init__.py create mode 100644 miplearn/components/steps/convert_tight.py create mode 100644 miplearn/components/steps/drop_redundant.py create mode 100644 miplearn/components/steps/relax_integrality.py diff --git a/miplearn/components/relaxation.py b/miplearn/components/relaxation.py index 8cd3fca..a3d97e5 100644 --- a/miplearn/components/relaxation.py +++ b/miplearn/components/relaxation.py @@ -3,16 +3,13 @@ # Released under the modified BSD license. See COPYING.md for more details. import logging -from copy import deepcopy - -from tqdm import tqdm from miplearn import Component from miplearn.classifiers.counting import CountingClassifier -from miplearn.components import classifier_evaluation_dict from miplearn.components.composite import CompositeComponent -from miplearn.components.lazy_static import LazyConstraint -from miplearn.extractors import InstanceIterator +from miplearn.components.steps.convert_tight import ConvertTightIneqsIntoEqsStep +from miplearn.components.steps.drop_redundant import DropRedundantInequalitiesStep +from miplearn.components.steps.relax_integrality import RelaxIntegralityStep logger = logging.getLogger(__name__) @@ -20,17 +17,11 @@ logger = logging.getLogger(__name__) class RelaxationComponent(Component): """ A Component that tries to build a relaxation that is simultaneously strong and easy - to solve. - - Currently, this component performs the following operations: - - Drops all integrality constraints - - Drops all inequality constraints that are likely redundant, and optionally - double checks that all dropped constraints are actually satisfied. - - Converts inequalities that are likely binding into equalities, and double - checks all resulting equalities have zero marginal costs. + to solve. Currently, this component is composed by three steps: - In future versions of MIPLearn, this component may keep some integrality constraints - and perform other operations. + - RelaxIntegralityStep + - DropRedundantInequalitiesStep + - ConvertTightIneqsIntoEqsStep Parameters ---------- @@ -103,293 +94,3 @@ class RelaxationComponent(Component): def iteration_cb(self, solver, instance, model): return self.composite.iteration_cb(solver, instance, model) - - -class RelaxIntegralityStep(Component): - def before_solve(self, solver, instance, _): - logger.info("Relaxing integrality...") - solver.internal_solver.relax() - - -class DropRedundantInequalitiesStep(Component): - def __init__( - self, - classifier=CountingClassifier(), - threshold=0.95, - slack_tolerance=1e-5, - check_dropped=False, - violation_tolerance=1e-5, - max_iterations=3, - ): - self.classifiers = {} - self.classifier_prototype = classifier - self.threshold = threshold - self.slack_tolerance = slack_tolerance - self.pool = [] - self.check_dropped = check_dropped - self.violation_tolerance = violation_tolerance - self.max_iterations = max_iterations - self.current_iteration = 0 - - def before_solve(self, solver, instance, _): - self.current_iteration = 0 - - 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) - for category in y.keys(): - for i in range(len(y[category])): - if y[category][i][0] == 1: - cid = constraints[category][i] - c = LazyConstraint( - cid=cid, - obj=solver.internal_solver.extract_constraint(cid), - ) - self.pool += [c] - logger.info("Extracted %d predicted constraints" % len(self.pool)) - - def after_solve(self, solver, instance, model, results): - instance.slacks = solver.internal_solver.get_constraint_slacks() - - def fit(self, training_instances): - 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 (rlx:drop_ineq)"): - 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 tqdm( - InstanceIterator(instances), - desc="Extract (rlx:drop_ineq:x)", - disable=len(instances) < 5, - ): - 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 tqdm( - InstanceIterator(instances), - desc="Extract (rlx:drop_ineq:y)", - disable=len(instances) < 5, - ): - 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) - - def iteration_cb(self, solver, instance, model): - if not self.check_dropped: - return False - if self.current_iteration >= self.max_iterations: - return False - self.current_iteration += 1 - logger.debug("Checking that dropped constraints are satisfied...") - constraints_to_add = [] - for c in self.pool: - if not solver.internal_solver.is_constraint_satisfied( - c.obj, - self.violation_tolerance, - ): - constraints_to_add.append(c) - for c in constraints_to_add: - self.pool.remove(c) - solver.internal_solver.add_constraint(c.obj) - if len(constraints_to_add) > 0: - logger.info( - "%8d constraints %8d in the pool" - % (len(constraints_to_add), len(self.pool)) - ) - return True - else: - return False - - -class ConvertTightIneqsIntoEqsStep(Component): - 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("Predicting tight 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_converted = 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.set_constraint_sense(cid, "=") - n_converted += 1 - logger.info(f"Converted {n_converted} inequalities into equalities") - - def after_solve(self, solver, instance, model, results): - instance.slacks = solver.internal_solver.get_constraint_slacks() - - def fit(self, training_instances): - 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 (rlx:conv_ineqs)"): - 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 tqdm( - InstanceIterator(instances), - desc="Extract (rlx:conv_ineqs:x)", - disable=len(instances) < 5, - ): - 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 tqdm( - InstanceIterator(instances), - desc="Extract (rlx:conv_ineqs:y)", - disable=len(instances) < 5, - ): - 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/steps/__init__.py b/miplearn/components/steps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/miplearn/components/steps/convert_tight.py b/miplearn/components/steps/convert_tight.py new file mode 100644 index 0000000..013ff59 --- /dev/null +++ b/miplearn/components/steps/convert_tight.py @@ -0,0 +1,153 @@ +# 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 +from copy import deepcopy + +from tqdm import tqdm + +from miplearn import Component +from miplearn.classifiers.counting import CountingClassifier +from miplearn.components import classifier_evaluation_dict +from miplearn.extractors import InstanceIterator + +logger = logging.getLogger(__name__) + + +class ConvertTightIneqsIntoEqsStep(Component): + """ + Component that predicts which inequality constraints are likely to be binding in + the LP relaxation of the problem and converts them into equality constraints. + Optionally double checks that the conversion process did not affect feasibility + or optimality of the problem. + + This component does not work on MIPs. All integrality constraints must be relaxed + before this component is used. + """ + + 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("Predicting tight 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_converted = 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.set_constraint_sense(cid, "=") + n_converted += 1 + logger.info(f"Converted {n_converted} inequalities into equalities") + + def after_solve(self, solver, instance, model, results): + instance.slacks = solver.internal_solver.get_constraint_slacks() + + def fit(self, training_instances): + 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 (rlx:conv_ineqs)"): + 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 tqdm( + InstanceIterator(instances), + desc="Extract (rlx:conv_ineqs:x)", + disable=len(instances) < 5, + ): + 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 tqdm( + InstanceIterator(instances), + desc="Extract (rlx:conv_ineqs:y)", + disable=len(instances) < 5, + ): + 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/steps/drop_redundant.py b/miplearn/components/steps/drop_redundant.py new file mode 100644 index 0000000..7682bd2 --- /dev/null +++ b/miplearn/components/steps/drop_redundant.py @@ -0,0 +1,186 @@ +# 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 +from copy import deepcopy + +from tqdm import tqdm + +from miplearn import Component +from miplearn.classifiers.counting import CountingClassifier +from miplearn.components import classifier_evaluation_dict +from miplearn.components.lazy_static import LazyConstraint +from miplearn.extractors import InstanceIterator + +logger = logging.getLogger(__name__) + + +class DropRedundantInequalitiesStep(Component): + """ + Component that predicts which inequalities are likely loose in the LP and removes + them. Optionally, double checks after the problem is solved that all dropped + inequalities were in fact redundant, and, if not, re-adds them to the problem. + + This component does not work on MIPs. All integrality constraints must be relaxed + before this component is used. + """ + + def __init__( + self, + classifier=CountingClassifier(), + threshold=0.95, + slack_tolerance=1e-5, + check_dropped=False, + violation_tolerance=1e-5, + max_iterations=3, + ): + self.classifiers = {} + self.classifier_prototype = classifier + self.threshold = threshold + self.slack_tolerance = slack_tolerance + self.pool = [] + self.check_dropped = check_dropped + self.violation_tolerance = violation_tolerance + self.max_iterations = max_iterations + self.current_iteration = 0 + + def before_solve(self, solver, instance, _): + self.current_iteration = 0 + + 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) + for category in y.keys(): + for i in range(len(y[category])): + if y[category][i][0] == 1: + cid = constraints[category][i] + c = LazyConstraint( + cid=cid, + obj=solver.internal_solver.extract_constraint(cid), + ) + self.pool += [c] + logger.info("Extracted %d predicted constraints" % len(self.pool)) + + def after_solve(self, solver, instance, model, results): + instance.slacks = solver.internal_solver.get_constraint_slacks() + + def fit(self, training_instances): + 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 (rlx:drop_ineq)"): + 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 tqdm( + InstanceIterator(instances), + desc="Extract (rlx:drop_ineq:x)", + disable=len(instances) < 5, + ): + 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 tqdm( + InstanceIterator(instances), + desc="Extract (rlx:drop_ineq:y)", + disable=len(instances) < 5, + ): + 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) + + def iteration_cb(self, solver, instance, model): + if not self.check_dropped: + return False + if self.current_iteration >= self.max_iterations: + return False + self.current_iteration += 1 + logger.debug("Checking that dropped constraints are satisfied...") + constraints_to_add = [] + for c in self.pool: + if not solver.internal_solver.is_constraint_satisfied( + c.obj, + self.violation_tolerance, + ): + constraints_to_add.append(c) + for c in constraints_to_add: + self.pool.remove(c) + solver.internal_solver.add_constraint(c.obj) + if len(constraints_to_add) > 0: + logger.info( + "%8d constraints %8d in the pool" + % (len(constraints_to_add), len(self.pool)) + ) + return True + else: + return False diff --git a/miplearn/components/steps/relax_integrality.py b/miplearn/components/steps/relax_integrality.py new file mode 100644 index 0000000..81d953f --- /dev/null +++ b/miplearn/components/steps/relax_integrality.py @@ -0,0 +1,19 @@ +# 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 + +from miplearn import Component + +logger = logging.getLogger(__name__) + + +class RelaxIntegralityStep(Component): + """ + Component that relaxes all integrality constraints before the problem is solved. + """ + + def before_solve(self, solver, instance, _): + logger.info("Relaxing integrality...") + solver.internal_solver.relax() diff --git a/miplearn/components/tests/test_relaxation.py b/miplearn/components/tests/test_relaxation.py index 47343d4..e901543 100644 --- a/miplearn/components/tests/test_relaxation.py +++ b/miplearn/components/tests/test_relaxation.py @@ -4,7 +4,7 @@ from unittest.mock import Mock, call -from miplearn import RelaxationComponent, LearningSolver, Instance, InternalSolver +from miplearn import LearningSolver, Instance, InternalSolver from miplearn.classifiers import Classifier from miplearn.components.relaxation import DropRedundantInequalitiesStep