diff --git a/miplearn/components/component.py b/miplearn/components/component.py index 3135307..d791b4e 100644 --- a/miplearn/components/component.py +++ b/miplearn/components/component.py @@ -21,3 +21,6 @@ class Component(ABC): @abstractmethod def fit(self, training_instances): pass + + def after_iteration(self, solver, instance, model): + return False diff --git a/miplearn/components/lazy_dynamic.py b/miplearn/components/lazy_dynamic.py index 2e961c2..02eafe7 100644 --- a/miplearn/components/lazy_dynamic.py +++ b/miplearn/components/lazy_dynamic.py @@ -38,6 +38,18 @@ class DynamicLazyConstraintsComponent(Component): cut = instance.build_lazy_constraint(model, v) solver.internal_solver.add_constraint(cut) + def after_iteration(self, solver, instance, model): + logger.debug("Finding violated (dynamic) lazy constraints...") + violations = instance.find_violated_lazy_constraints(model) + if len(violations) == 0: + return False + instance.found_violated_lazy_constraints += violations + logger.debug(" %d violations found" % len(violations)) + for v in violations: + cut = instance.build_lazy_constraint(model, v) + solver.internal_solver.add_constraint(cut) + return True + def after_solve(self, solver, instance, model, results): pass diff --git a/miplearn/components/lazy_static.py b/miplearn/components/lazy_static.py index c4511c3..39004dd 100644 --- a/miplearn/components/lazy_static.py +++ b/miplearn/components/lazy_static.py @@ -35,13 +35,20 @@ class StaticLazyConstraintsComponent(Component): def after_solve(self, solver, instance, model, results): pass - def on_callback(self, solver, instance, model): - print(self.pool) + def after_iteration(self, solver, instance, model): + logger.debug("Finding violated (static) lazy constraints...") + n_added = 0 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] + n_added += 1 + if n_added > 0: + logger.debug(" %d violations found" % n_added) + return True + else: + return False def fit(self, training_instances): logger.debug("Extracting x and y...") diff --git a/miplearn/components/tests/test_lazy_static.py b/miplearn/components/tests/test_lazy_static.py index baa9302..34e52f7 100644 --- a/miplearn/components/tests/test_lazy_static.py +++ b/miplearn/components/tests/test_lazy_static.py @@ -95,8 +95,9 @@ def test_usage_with_solver(): ]) internal.add_constraint.reset_mock() - # LearningSolver calls callback (first time) - component.on_callback(solver, instance, None) + # LearningSolver calls after_iteration (first time) + should_repeat = component.after_iteration(solver, instance, None) + assert should_repeat # Should ask internal solver to verify if constraints in the pool are # satisfied and add the ones that are not @@ -105,8 +106,9 @@ def test_usage_with_solver(): internal.add_constraint.assert_called_once_with("") internal.add_constraint.reset_mock() - # LearningSolver calls callback (second time) - component.on_callback(solver, instance, None) + # LearningSolver calls after_iteration (second time) + should_repeat = component.after_iteration(solver, instance, None) + assert not should_repeat # The lazy constraint pool should be empty by now, so no calls should be made internal.is_constraint_satisfied.assert_not_called() diff --git a/miplearn/problems/tests/test_tsp.py b/miplearn/problems/tests/test_tsp.py index 71c3c06..9d565d6 100644 --- a/miplearn/problems/tests/test_tsp.py +++ b/miplearn/problems/tests/test_tsp.py @@ -19,56 +19,56 @@ def test_generator(): assert len(instances) == 100 assert instances[0].n_cities == 100 assert norm(instances[0].distances - instances[0].distances.T) < 1e-6 - d = [instance.distances[0,1] for instance in instances] + d = [instance.distances[0, 1] for instance in instances] assert np.std(d) > 0 - -# def test_instance(): -# n_cities = 4 -# distances = np.array([ -# [0., 1., 2., 1.], -# [1., 0., 1., 2.], -# [2., 1., 0., 1.], -# [1., 2., 1., 0.], -# ]) -# instance = TravelingSalesmanInstance(n_cities, distances) -# for solver_name in ['gurobi', 'cplex']: -# solver = LearningSolver(solver=solver_name) -# solver.solve(instance) -# x = instance.solution["x"] -# assert x[0,1] == 1.0 -# assert x[0,2] == 0.0 -# assert x[0,3] == 1.0 -# assert x[1,2] == 1.0 -# assert x[1,3] == 0.0 -# assert x[2,3] == 1.0 -# assert instance.lower_bound == 4.0 -# assert instance.upper_bound == 4.0 -# -# -# def test_subtour(): -# n_cities = 6 -# cities = np.array([ -# [0., 0.], -# [1., 0.], -# [2., 0.], -# [3., 0.], -# [0., 1.], -# [3., 1.], -# ]) -# distances = squareform(pdist(cities)) -# instance = TravelingSalesmanInstance(n_cities, distances) -# for solver_name in ['gurobi', 'cplex']: -# solver = LearningSolver(solver=solver_name) -# solver.solve(instance) -# assert hasattr(instance, "found_violated_lazy_constraints") -# assert hasattr(instance, "found_violated_user_cuts") -# x = instance.solution["x"] -# assert x[0,1] == 1.0 -# assert x[0,4] == 1.0 -# assert x[1,2] == 1.0 -# assert x[2,3] == 1.0 -# assert x[3,5] == 1.0 -# assert x[4,5] == 1.0 -# solver.fit([instance]) -# solver.solve(instance) \ No newline at end of file + +def test_instance(): + n_cities = 4 + distances = np.array([ + [0., 1., 2., 1.], + [1., 0., 1., 2.], + [2., 1., 0., 1.], + [1., 2., 1., 0.], + ]) + instance = TravelingSalesmanInstance(n_cities, distances) + for solver_name in ['gurobi', 'cplex']: + solver = LearningSolver(solver=solver_name) + solver.solve(instance) + x = instance.solution["x"] + assert x[0, 1] == 1.0 + assert x[0, 2] == 0.0 + assert x[0, 3] == 1.0 + assert x[1, 2] == 1.0 + assert x[1, 3] == 0.0 + assert x[2, 3] == 1.0 + assert instance.lower_bound == 4.0 + assert instance.upper_bound == 4.0 + + +def test_subtour(): + n_cities = 6 + cities = np.array([ + [0., 0.], + [1., 0.], + [2., 0.], + [3., 0.], + [0., 1.], + [3., 1.], + ]) + distances = squareform(pdist(cities)) + instance = TravelingSalesmanInstance(n_cities, distances) + for solver_name in ['gurobi', 'cplex']: + solver = LearningSolver(solver=solver_name) + solver.solve(instance) + assert hasattr(instance, "found_violated_lazy_constraints") + assert hasattr(instance, "found_violated_user_cuts") + x = instance.solution["x"] + assert x[0, 1] == 1.0 + assert x[0, 4] == 1.0 + assert x[1, 2] == 1.0 + assert x[2, 3] == 1.0 + assert x[3, 5] == 1.0 + assert x[4, 5] == 1.0 + solver.fit([instance]) + solver.solve(instance) diff --git a/miplearn/solvers/internal.py b/miplearn/solvers/internal.py index 691b324..a004166 100644 --- a/miplearn/solvers/internal.py +++ b/miplearn/solvers/internal.py @@ -126,7 +126,7 @@ class InternalSolver(ABC): Parameters ---------- - iteration_cb: function + iteration_cb: () -> Bool By default, InternalSolver makes a single call to the native `solve` method and returns the result. If an iteration callback is provided instead, InternalSolver enters a loop, where `solve` and `iteration_cb` diff --git a/miplearn/solvers/learning.py b/miplearn/solvers/learning.py index 95e47a5..1cc7919 100644 --- a/miplearn/solvers/learning.py +++ b/miplearn/solvers/learning.py @@ -171,8 +171,15 @@ class LearningSolver: if relaxation_only: return results + def iteration_cb(): + should_repeat = False + for component in self.components.values(): + if component.after_iteration(self, instance, model): + should_repeat = True + return should_repeat + logger.info("Solving MILP...") - results = self.internal_solver.solve(tee=tee) + results = self.internal_solver.solve(tee=tee, iteration_cb=iteration_cb) results["LP value"] = instance.lp_value # Read MIP solution and bounds diff --git a/miplearn/solvers/tests/test_learning_solver.py b/miplearn/solvers/tests/test_learning_solver.py index cb64050..718ed22 100644 --- a/miplearn/solvers/tests/test_learning_solver.py +++ b/miplearn/solvers/tests/test_learning_solver.py @@ -64,4 +64,4 @@ def test_add_components(): solver.add(DynamicLazyConstraintsComponent()) solver.add(DynamicLazyConstraintsComponent()) assert len(solver.components) == 1 - assert "LazyConstraintsComponent" in solver.components + assert "DynamicLazyConstraintsComponent" in solver.components