From e731f46b725fec16c3a240d786d277ec5160db53 Mon Sep 17 00:00:00 2001 From: Alinson S Xavier Date: Wed, 23 Sep 2020 14:27:17 -0500 Subject: [PATCH] Implement preliminary version of static lazy component --- miplearn/__init__.py | 1 + miplearn/components/lazy_static.py | 125 ++++++++++++ miplearn/components/tests/test_lazy_static.py | 181 ++++++++++++++++++ miplearn/instance.py | 12 ++ miplearn/solvers/internal.py | 25 +++ 5 files changed, 344 insertions(+) create mode 100644 miplearn/components/lazy_static.py create mode 100644 miplearn/components/tests/test_lazy_static.py diff --git a/miplearn/__init__.py b/miplearn/__init__.py index ad8e3fc..13ad64e 100644 --- a/miplearn/__init__.py +++ b/miplearn/__init__.py @@ -10,6 +10,7 @@ from .extractors import (SolutionExtractor, from .components.component import Component from .components.objective import ObjectiveValueComponent from .components.lazy import LazyConstraintsComponent +from .components.lazy_static import StaticLazyConstraintsComponent from .components.cuts import UserCutsComponent from .components.primal import PrimalSolutionComponent diff --git a/miplearn/components/lazy_static.py b/miplearn/components/lazy_static.py new file mode 100644 index 0000000..c4511c3 --- /dev/null +++ b/miplearn/components/lazy_static.py @@ -0,0 +1,125 @@ +# 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 sys +from copy import deepcopy + +from miplearn.classifiers.counting import CountingClassifier +from .component import Component +from ..extractors import * + +logger = logging.getLogger(__name__) + + +class LazyConstraint: + def __init__(self, cid, obj): + self.cid = cid + self.obj = obj + + +class StaticLazyConstraintsComponent(Component): + def __init__(self, + classifier=CountingClassifier(), + threshold=0.05): + self.threshold = threshold + self.classifier_prototype = classifier + self.classifiers = {} + self.pool = [] + + def before_solve(self, solver, instance, model): + instance.found_violated_lazy_constraints = [] + if instance.has_static_lazy_constraints(): + self._extract_and_predict_static(solver, instance) + + def after_solve(self, solver, instance, model, results): + pass + + def on_callback(self, solver, instance, model): + print(self.pool) + for c in self.pool: + if not solver.internal_solver.is_constraint_satisfied(c.obj): + self.pool.remove(c) + solver.internal_solver.add_constraint(c.obj) + instance.found_violated_lazy_constraints += [c.cid] + + 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 (lazy)", + 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 predict(self, instance): + pass + + def evaluate(self, instances): + pass + + def _extract_and_predict_static(self, solver, instance): + x = {} + constraints = {} + for cid in solver.internal_solver.get_constraint_names(): + if instance.is_constraint_lazy(cid): + category = instance.get_lazy_constraint_category(cid) + if category not in self.classifiers: + continue + if category not in x: + x[category] = [] + constraints[category] = [] + x[category] += [instance.get_lazy_constraint_features(cid)] + c = LazyConstraint(cid=cid, + obj=solver.internal_solver.extract_constraint(cid)) + constraints[category] += [c] + self.pool.append(c) + for (category, x_values) in x.items(): + if isinstance(x_values[0], np.ndarray): + x[category] = np.array(x_values) + proba = self.classifiers[category].predict_proba(x[category]) + for i in range(len(proba)): + if proba[i][1] > self.threshold: + c = constraints[category][i] + self.pool.remove(c) + solver.internal_solver.add_constraint(c.obj) + instance.found_violated_lazy_constraints += [c.cid] + + def _collect_constraints(self, train_instances): + constraints = {} + for instance in train_instances: + for cid in instance.found_violated_lazy_constraints: + category = instance.get_lazy_constraint_category(cid) + if category not in constraints: + constraints[category] = set() + constraints[category].add(cid) + for (category, cids) in constraints.items(): + constraints[category] = sorted(list(cids)) + return constraints + + def x(self, train_instances): + result = {} + constraints = self._collect_constraints(train_instances) + for (category, cids) in constraints.items(): + result[category] = [] + for instance in train_instances: + for cid in cids: + result[category].append(instance.get_lazy_constraint_features(cid)) + return result + + def y(self, train_instances): + result = {} + constraints = self._collect_constraints(train_instances) + for (category, cids) in constraints.items(): + result[category] = [] + for instance in train_instances: + for cid in cids: + if cid in instance.found_violated_lazy_constraints: + result[category].append([0, 1]) + else: + result[category].append([1, 0]) + return result diff --git a/miplearn/components/tests/test_lazy_static.py b/miplearn/components/tests/test_lazy_static.py new file mode 100644 index 0000000..baa9302 --- /dev/null +++ b/miplearn/components/tests/test_lazy_static.py @@ -0,0 +1,181 @@ +# 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 (StaticLazyConstraintsComponent, + 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_names = Mock(return_value=["c1", "c2", "c3", "c4"]) + internal.extract_constraint = Mock(side_effect=lambda cid: "<%s>" % cid) + internal.is_constraint_satisfied = Mock(return_value=False) + + instance = Mock(spec=Instance) + instance.has_static_lazy_constraints = Mock(return_value=True) + instance.is_constraint_lazy = Mock(side_effect=lambda cid: { + "c1": False, + "c2": True, + "c3": True, + "c4": True, + }[cid]) + instance.get_lazy_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: { + "c2": "type-a", + "c3": "type-a", + "c4": "type-b", + }[cid]) + + component = StaticLazyConstraintsComponent(threshold=0.90) + 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 ask if instance has static lazy constraints + instance.has_static_lazy_constraints.assert_called_once() + + # Should ask internal solver for a list of constraints in the model + internal.get_constraint_names.assert_called_once() + + # Should ask if each constraint in the model is lazy + instance.is_constraint_lazy.assert_has_calls([ + call("c1"), call("c2"), call("c3"), call("c4"), + ]) + + # For the lazy ones, should ask for features + instance.get_lazy_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([ + call("c2"), call("c3"), call("c4"), + ]) + + # Should ask internal solver to remove constraints identified as lazy + assert internal.extract_constraint.call_count == 3 + internal.extract_constraint.assert_has_calls([ + call("c2"), call("c3"), call("c4"), + ]) + + # Should ask ML to predict whether each lazy constraint should be enforced + 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]]) + + # For the ones that should be enforced, should ask solver to re-add them + # to the formulation. The remaining ones should remain in the pool. + assert internal.add_constraint.call_count == 2 + internal.add_constraint.assert_has_calls([ + call(""), call(""), + ]) + internal.add_constraint.reset_mock() + + # LearningSolver calls callback (first time) + component.on_callback(solver, instance, None) + + # Should ask internal solver to verify if constraints in the pool are + # satisfied and add the ones that are not + internal.is_constraint_satisfied.assert_called_once_with("") + internal.is_constraint_satisfied.reset_mock() + internal.add_constraint.assert_called_once_with("") + internal.add_constraint.reset_mock() + + # LearningSolver calls callback (second time) + component.on_callback(solver, instance, None) + + # The lazy constraint pool should be empty by now, so no calls should be made + internal.is_constraint_satisfied.assert_not_called() + internal.add_constraint.assert_not_called() + + # Should update instance object + assert instance.found_violated_lazy_constraints == ["c3", "c4", "c2"] + + +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: { + "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: { + "c1": [1, 1], + "c2": [1, 2], + "c3": [1, 3], + "c4": [1, 4, 0], + "c5": [1, 5, 0], + }[cid]) + + 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: { + "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: { + "c1": [2, 1], + "c2": [2, 2], + "c3": [2, 3], + "c4": [2, 4, 0], + "c5": [2, 5, 0], + }[cid]) + + instances = [instance_1, instance_2] + component = StaticLazyConstraintsComponent() + component.classifiers = { + "type-a": Mock(spec=Classifier), + "type-b": Mock(spec=Classifier), + } + + expected_constraints = { + "type-a": ["c1", "c2", "c3"], + "type-b": ["c4", "c5"], + } + expected_x = { + "type-a": [[1, 1], [1, 2], [1, 3], [2, 1], [2, 2], [2, 3]], + "type-b": [[1, 4, 0], [1, 5, 0], [2, 4, 0], [2, 5, 0]] + } + expected_y = { + "type-a": [[0, 1], [0, 1], [1, 0], [1, 0], [0, 1], [0, 1]], + "type-b": [[0, 1], [0, 1], [0, 1], [1, 0]] + } + assert component._collect_constraints(instances) == expected_constraints + assert component.x(instances) == expected_x + assert component.y(instances) == expected_y + + component.fit(instances) + component.classifiers["type-a"].fit.assert_called_once_with(expected_x["type-a"], + expected_y["type-a"]) + component.classifiers["type-b"].fit.assert_called_once_with(expected_x["type-b"], + expected_y["type-b"]) diff --git a/miplearn/instance.py b/miplearn/instance.py index a5c348c..b275523 100644 --- a/miplearn/instance.py +++ b/miplearn/instance.py @@ -77,6 +77,18 @@ class Instance(ABC): """ return "default" + def has_static_lazy_constraints(self): + return False + + def has_dynamic_lazy_constraints(self): + return False + + def is_constraint_lazy(self, cid): + return False + + def get_lazy_constraint_features(self, cid): + pass + def find_violated_lazy_constraints(self, model): """ Returns lazy constraint violations found for the current solution. diff --git a/miplearn/solvers/internal.py b/miplearn/solvers/internal.py index f52f6a4..dc9417c 100644 --- a/miplearn/solvers/internal.py +++ b/miplearn/solvers/internal.py @@ -8,6 +8,10 @@ from abc import ABC, abstractmethod logger = logging.getLogger(__name__) +class ExtractedConstraint(ABC): + pass + + class InternalSolver(ABC): """ Abstract class representing the MIP solver used internally by LearningSolver. @@ -134,6 +138,27 @@ class InternalSolver(ABC): """ pass + # @abstractmethod + def get_constraint_names(self): + """ + Returns a list of strings, containing the name of each constraint in the + model. + """ + pass + + # @abstractmethod + def extract_constraint(self, cname): + """ + Removes a given constraint from the model and returns an object `c` which + can be used to verify if the removed constraint is still satisfied by + the current solution, using `is_constraint_satisfied(c)`, and can potentially + be re-added to the model using `add_constraint(c)`. + """ + pass + + def is_constraint_satisfied(self, c): + pass + @abstractmethod def set_threads(self, threads): pass