From a4433916e52ca450909cf6f745fbed5d41076f4e Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Tue, 13 Apr 2021 08:42:06 -0500 Subject: [PATCH] Update DynamicLazyConstraintsComponent --- miplearn/components/component.py | 2 +- miplearn/components/dynamic_common.py | 61 ++++++++++++++++--- miplearn/components/dynamic_lazy.py | 49 ++++++++------- miplearn/components/dynamic_user_cuts.py | 2 +- miplearn/instance/base.py | 8 +-- miplearn/solvers/learning.py | 3 + tests/components/test_dynamic_lazy.py | 77 ++++++++++++++++-------- tests/problems/test_tsp.py | 5 +- 8 files changed, 144 insertions(+), 63 deletions(-) diff --git a/miplearn/components/component.py b/miplearn/components/component.py index 2b45155..18ed448 100644 --- a/miplearn/components/component.py +++ b/miplearn/components/component.py @@ -196,7 +196,7 @@ class Component(EnforceOverrides): ) -> None: x, y = self.xy_instances(training_instances) for cat in x.keys(): - x[cat] = np.array(x[cat]) + x[cat] = np.array(x[cat], dtype=np.float32) y[cat] = np.array(y[cat]) self.fit_xy(x, y) diff --git a/miplearn/components/dynamic_common.py b/miplearn/components/dynamic_common.py index acf7f81..28992bc 100644 --- a/miplearn/components/dynamic_common.py +++ b/miplearn/components/dynamic_common.py @@ -105,7 +105,10 @@ class DynamicConstraintsComponent(Component): features.extend(sample.after_lp.instance.to_list()) features.extend(instance.get_constraint_features(cid)) for ci in features: - assert isinstance(ci, float) + 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) @@ -137,7 +140,7 @@ class DynamicConstraintsComponent(Component): x, y, _ = self.sample_xy_with_cids(instance, sample) return x, y - def sample_predict( + def sample_predict_old( self, instance: Instance, sample: TrainingSample, @@ -160,6 +163,29 @@ class DynamicConstraintsComponent(Component): pred += [cids[category][i]] return pred + def sample_predict( + self, + instance: Instance, + sample: Sample, + ) -> List[Hashable]: + pred: List[Hashable] = [] + if len(self.known_cids) == 0: + logger.info("Classifiers not fitted. Skipping.") + return pred + x, _, cids = self.sample_xy_with_cids(instance, sample) + for category in x.keys(): + assert category in self.classifiers + assert category in self.thresholds + clf = self.classifiers[category] + thr = self.thresholds[category] + nx = np.array(x[category]) + proba = clf.predict_proba(nx) + t = thr.predict(nx) + for i in range(proba.shape[0]): + if proba[i][1] > t[1]: + pred += [cids[category][i]] + return pred + @overrides def fit_old(self, training_instances: List[Instance]) -> None: collected_cids = set() @@ -174,6 +200,24 @@ class DynamicConstraintsComponent(Component): self.known_cids.extend(sorted(collected_cids)) super().fit_old(training_instances) + @overrides + def fit(self, training_instances: List[Instance]) -> None: + collected_cids = set() + for instance in training_instances: + instance.load() + for sample in instance.samples: + if ( + sample.after_mip is None + or sample.after_mip.extra is None + or sample.after_mip.extra[self.attr] is None + ): + continue + collected_cids |= sample.after_mip.extra[self.attr] + instance.free() + self.known_cids.clear() + self.known_cids.extend(sorted(collected_cids)) + super().fit(training_instances) + @overrides def fit_xy( self, @@ -189,12 +233,15 @@ class DynamicConstraintsComponent(Component): self.thresholds[category].fit(self.classifiers[category], npx, npy) @overrides - def sample_evaluate_old( + def sample_evaluate( self, instance: Instance, - sample: TrainingSample, + sample: Sample, ) -> Dict[Hashable, Dict[str, float]]: - assert getattr(sample, self.attr) is not None + 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] pred = set(self.sample_predict(instance, sample)) tp: Dict[Hashable, int] = {} tn: Dict[Hashable, int] = {} @@ -210,12 +257,12 @@ class DynamicConstraintsComponent(Component): fp[category] = 0 fn[category] = 0 if cid in pred: - if cid in getattr(sample, self.attr): + if cid in actual: tp[category] += 1 else: fp[category] += 1 else: - if cid in getattr(sample, self.attr): + if cid in actual: fn[category] += 1 else: tn[category] += 1 diff --git a/miplearn/components/dynamic_lazy.py b/miplearn/components/dynamic_lazy.py index 3525313..d676af2 100644 --- a/miplearn/components/dynamic_lazy.py +++ b/miplearn/components/dynamic_lazy.py @@ -3,7 +3,7 @@ # 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 +from typing import Dict, List, TYPE_CHECKING, Hashable, Tuple, Any, Optional, Set import numpy as np from overrides import overrides @@ -41,6 +41,7 @@ class DynamicLazyConstraintsComponent(Component): self.classifiers = self.dynamic.classifiers self.thresholds = self.dynamic.thresholds self.known_cids = self.dynamic.known_cids + self.lazy_enforced: Set[str] = set() @staticmethod def enforce( @@ -54,21 +55,33 @@ class DynamicLazyConstraintsComponent(Component): instance.enforce_lazy_constraint(solver.internal_solver, model, cid) @overrides - def before_solve_mip_old( + def before_solve_mip( self, solver: "LearningSolver", instance: Instance, model: Any, stats: LearningSolveStats, - features: Features, - training_data: TrainingSample, + sample: Sample, ) -> None: - training_data.lazy_enforced = set() + self.lazy_enforced.clear() logger.info("Predicting violated (dynamic) lazy constraints...") - cids = self.dynamic.sample_predict(instance, training_data) + cids = self.dynamic.sample_predict(instance, sample) logger.info("Enforcing %d lazy constraints..." % len(cids)) self.enforce(cids, instance, model, solver) + @overrides + def after_solve_mip( + self, + solver: "LearningSolver", + instance: Instance, + model: Any, + 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) + @overrides def iteration_cb( self, @@ -83,23 +96,13 @@ class DynamicLazyConstraintsComponent(Component): logger.debug("No violations found") return False else: - sample = instance.training_data[-1] - assert sample.lazy_enforced is not None - sample.lazy_enforced |= set(cids) + self.lazy_enforced |= set(cids) logger.debug(" %d violations found" % len(cids)) self.enforce(cids, instance, model, solver) return True # Delegate ML methods to self.dynamic # ------------------------------------------------------------------- - @overrides - def sample_xy_old( - self, - instance: Instance, - sample: TrainingSample, - ) -> Tuple[Dict, Dict]: - return self.dynamic.sample_xy_old(instance, sample) - @overrides def sample_xy( self, @@ -111,13 +114,13 @@ class DynamicLazyConstraintsComponent(Component): def sample_predict( self, instance: Instance, - sample: TrainingSample, + sample: Sample, ) -> List[Hashable]: return self.dynamic.sample_predict(instance, sample) @overrides - def fit_old(self, training_instances: List[Instance]) -> None: - self.dynamic.fit_old(training_instances) + def fit(self, training_instances: List[Instance]) -> None: + self.dynamic.fit(training_instances) @overrides def fit_xy( @@ -128,9 +131,9 @@ class DynamicLazyConstraintsComponent(Component): self.dynamic.fit_xy(x, y) @overrides - def sample_evaluate_old( + def sample_evaluate( self, instance: Instance, - sample: TrainingSample, + sample: Sample, ) -> Dict[Hashable, Dict[str, float]]: - return self.dynamic.sample_evaluate_old(instance, sample) + return self.dynamic.sample_evaluate(instance, sample) diff --git a/miplearn/components/dynamic_user_cuts.py b/miplearn/components/dynamic_user_cuts.py index 34d05ca..67e9037 100644 --- a/miplearn/components/dynamic_user_cuts.py +++ b/miplearn/components/dynamic_user_cuts.py @@ -51,7 +51,7 @@ class UserCutsComponent(Component): self.enforced.clear() self.n_added_in_callback = 0 logger.info("Predicting violated user cuts...") - cids = self.dynamic.sample_predict(instance, training_data) + cids = self.dynamic.sample_predict_old(instance, training_data) logger.info("Enforcing %d user cuts ahead-of-time..." % len(cids)) for cid in cids: instance.enforce_user_cut(solver.internal_solver, model, cid) diff --git a/miplearn/instance/base.py b/miplearn/instance/base.py index 1d80d04..f240981 100644 --- a/miplearn/instance/base.py +++ b/miplearn/instance/base.py @@ -62,9 +62,9 @@ class Instance(ABC, EnforceOverrides): the problem. If two instances map into arrays of different lengths, they cannot be solved by the same LearningSolver object. - By default, returns [0]. + By default, returns [0.0]. """ - return [0] + return [0.0] def get_variable_features(self, var_name: VariableName) -> List[float]: """ @@ -81,9 +81,9 @@ class Instance(ABC, EnforceOverrides): length for all variables within the same category, for all relevant instances of the problem. - By default, returns [0]. + By default, returns [0.0]. """ - return [0] + return [0.0] def get_variable_category(self, var_name: VariableName) -> Optional[Category]: """ diff --git a/miplearn/solvers/learning.py b/miplearn/solvers/learning.py index 210e391..828d5c0 100644 --- a/miplearn/solvers/learning.py +++ b/miplearn/solvers/learning.py @@ -159,6 +159,7 @@ class LearningSolver: # ------------------------------------------------------- logger.info("Extracting features (after-load)...") features = FeaturesExtractor(self.internal_solver).extract(instance) + features.extra = {} instance.features.__dict__ = features.__dict__ sample.after_load = features @@ -204,6 +205,7 @@ class LearningSolver: # ------------------------------------------------------- logger.info("Extracting features (after-lp)...") features = FeaturesExtractor(self.internal_solver).extract(instance) + features.extra = {} features.lp_solve = lp_stats sample.after_lp = features @@ -267,6 +269,7 @@ class LearningSolver: logger.info("Extracting features (after-mip)...") features = FeaturesExtractor(self.internal_solver).extract(instance) features.mip_solve = mip_stats + features.extra = {} sample.after_mip = features # Add some information to training_sample diff --git a/tests/components/test_dynamic_lazy.py b/tests/components/test_dynamic_lazy.py index 65de51d..8816926 100644 --- a/tests/components/test_dynamic_lazy.py +++ b/tests/components/test_dynamic_lazy.py @@ -83,15 +83,20 @@ def training_instances() -> List[Instance]: instances = [cast(Instance, Mock(spec=Instance)) for _ in range(2)] instances[0].samples = [ Sample( - after_lp=Features( - instance=InstanceFeatures(), - ), + after_lp=Features(instance=InstanceFeatures()), after_mip=Features(extra={"lazy_enforced": {"c1", "c2"}}), - ) + ), + Sample( + after_lp=Features(instance=InstanceFeatures()), + after_mip=Features(extra={"lazy_enforced": {"c2", "c3"}}), + ), ] instances[0].samples[0].after_lp.instance.to_list = Mock( # type: ignore return_value=[5.0] ) + instances[0].samples[1].after_lp.instance.to_list = Mock( # type: ignore + return_value=[5.0] + ) instances[0].get_constraint_category = Mock( # type: ignore side_effect=lambda cid: { "c1": "type-a", @@ -108,7 +113,30 @@ def training_instances() -> List[Instance]: "c4": [3.0, 4.0], }[cid] ) - + instances[1].samples = [ + Sample( + after_lp=Features(instance=InstanceFeatures()), + after_mip=Features(extra={"lazy_enforced": {"c3", "c4"}}), + ) + ] + instances[1].samples[0].after_lp.instance.to_list = Mock( # type: ignore + return_value=[8.0] + ) + instances[1].get_constraint_category = Mock( # type: ignore + side_effect=lambda cid: { + "c1": None, + "c2": "type-a", + "c3": "type-b", + "c4": "type-b", + }[cid] + ) + instances[1].get_constraint_features = Mock( # type: ignore + side_effect=lambda cid: { + "c2": [7.0, 8.0, 9.0], + "c3": [5.0, 6.0], + "c4": [7.0, 8.0], + }[cid] + ) return instances @@ -131,11 +159,11 @@ def test_sample_xy(training_instances: List[Instance]) -> None: assert_equals(y_actual, y_expected) -def test_fit_old(training_instances_old: List[Instance]) -> None: +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_old(training_instances_old) + comp.fit(training_instances) assert clf.clone.call_count == 2 assert "type-a" in comp.classifiers @@ -145,11 +173,11 @@ def test_fit_old(training_instances_old: List[Instance]) -> None: clf_a.fit.call_args[0][0], # type: ignore np.array( [ - [50.0, 1.0, 2.0, 3.0], - [50.0, 4.0, 5.0, 6.0], - [50.0, 1.0, 2.0, 3.0], - [50.0, 4.0, 5.0, 6.0], - [80.0, 7.0, 8.0, 9.0], + [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], ] ), ) @@ -173,12 +201,12 @@ def test_fit_old(training_instances_old: List[Instance]) -> None: clf_b.fit.call_args[0][0], # type: ignore np.array( [ - [50.0, 1.0, 2.0], - [50.0, 3.0, 4.0], - [50.0, 1.0, 2.0], - [50.0, 3.0, 4.0], - [80.0, 5.0, 6.0], - [80.0, 7.0, 8.0], + [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], ] ), ) @@ -197,7 +225,7 @@ def test_fit_old(training_instances_old: List[Instance]) -> None: ) -def test_sample_predict_evaluate_old(training_instances_old: List[Instance]) -> None: +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]) @@ -211,15 +239,14 @@ def test_sample_predict_evaluate_old(training_instances_old: List[Instance]) -> side_effect=lambda _: np.array([[0.9, 0.1], [0.1, 0.9]]) ) pred = comp.sample_predict( - training_instances_old[0], - training_instances_old[0].training_data[0], + training_instances[0], + training_instances[0].samples[0], ) assert pred == ["c1", "c4"] - ev = comp.sample_evaluate_old( - training_instances_old[0], - training_instances_old[0].training_data[0], + ev = comp.sample_evaluate( + training_instances[0], + training_instances[0].samples[0], ) - print(ev) 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), diff --git a/tests/problems/test_tsp.py b/tests/problems/test_tsp.py index 7499fd3..37288e2 100644 --- a/tests/problems/test_tsp.py +++ b/tests/problems/test_tsp.py @@ -67,8 +67,9 @@ def test_subtour() -> None: instance = TravelingSalesmanInstance(n_cities, distances) solver = LearningSolver() solver.solve(instance) - assert instance.training_data[0].lazy_enforced is not None - assert len(instance.training_data[0].lazy_enforced) > 0 + lazy_enforced = instance.samples[0].after_mip.extra["lazy_enforced"] + assert lazy_enforced is not None + assert len(lazy_enforced) > 0 solution = instance.training_data[0].solution assert solution is not None assert solution["x[(0, 1)]"] == 1.0