From 626d75f25e7b0cf185df21173048a119199a6f3d Mon Sep 17 00:00:00 2001 From: "Alinson S. Xavier" Date: Fri, 9 Apr 2021 20:33:48 -0500 Subject: [PATCH] Reorganize internal solver tests --- miplearn/solvers/gurobi.py | 7 +- miplearn/solvers/internal.py | 4 +- miplearn/solvers/pyomo/base.py | 9 +- miplearn/solvers/tests/__init__.py | 163 +++++++++++++++++--- tests/__init__.py | 18 --- tests/solvers/__init__.py | 17 +++ tests/solvers/test_internal_solver.py | 210 +++----------------------- tests/solvers/test_lazy_cb.py | 2 +- tests/solvers/test_learning_solver.py | 5 +- 9 files changed, 191 insertions(+), 244 deletions(-) diff --git a/miplearn/solvers/gurobi.py b/miplearn/solvers/gurobi.py index 0431efc..fdf38c5 100644 --- a/miplearn/solvers/gurobi.py +++ b/miplearn/solvers/gurobi.py @@ -6,7 +6,7 @@ import re import sys from io import StringIO from random import randint -from typing import List, Any, Dict, Optional +from typing import List, Any, Dict, Optional, Hashable from overrides import overrides @@ -517,3 +517,8 @@ class GurobiTestInstanceKnapsack(PyomoTestInstanceKnapsack): gp.quicksum(x[i] * self.prices[i] for i in range(n)), GRB.MAXIMIZE ) return model + + @overrides + def build_lazy_constraint(self, model: Any, violation: Hashable) -> Any: + x = model.getVarByName("x[0]") + return x <= 0.0 diff --git a/miplearn/solvers/internal.py b/miplearn/solvers/internal.py index 6a9bb5d..e0b3ca3 100644 --- a/miplearn/solvers/internal.py +++ b/miplearn/solvers/internal.py @@ -6,8 +6,6 @@ import logging from abc import ABC, abstractmethod from typing import Any, Dict, List, Optional -from overrides import EnforceOverrides - from miplearn.instance.base import Instance from miplearn.types import ( LPSolveStats, @@ -178,7 +176,7 @@ class InternalSolver(ABC): pass @abstractmethod - def add_constraint(self, cobj: Constraint) -> None: + def add_constraint(self, cobj: Constraint, name: str = "") -> None: """ Adds a single constraint to the model. """ diff --git a/miplearn/solvers/pyomo/base.py b/miplearn/solvers/pyomo/base.py index c8af6e4..5c157cb 100644 --- a/miplearn/solvers/pyomo/base.py +++ b/miplearn/solvers/pyomo/base.py @@ -6,7 +6,7 @@ import logging import re import sys from io import StringIO -from typing import Any, List, Dict, Optional +from typing import Any, List, Dict, Optional, Hashable import pyomo from overrides import overrides @@ -230,7 +230,7 @@ class BasePyomoSolver(InternalSolver): self._pyomo_solver.update_var(var) @overrides - def add_constraint(self, constraint: Any) -> Any: + def add_constraint(self, constraint: Any, name: str = "") -> Any: self._pyomo_solver.add_constraint(constraint) self._update_constrs() @@ -425,3 +425,8 @@ class PyomoTestInstanceKnapsack(Instance): self.weights[item], self.prices[item], ] + + @overrides + def build_lazy_constraint(self, model: Any, violation: Hashable) -> Any: + model.cut = pe.Constraint(expr=model.x[0] <= 0.0, name="cut") + return model.cut diff --git a/miplearn/solvers/tests/__init__.py b/miplearn/solvers/tests/__init__.py index 1033224..c85757e 100644 --- a/miplearn/solvers/tests/__init__.py +++ b/miplearn/solvers/tests/__init__.py @@ -2,22 +2,24 @@ # Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. # Released under the modified BSD license. See COPYING.md for more details. -from miplearn.solvers.internal import InternalSolver -from miplearn.instance.base import Instance from typing import Any +from miplearn.solvers.internal import InternalSolver -def assert_equals(left: Any, right: Any) -> None: - assert left == right, f"{left} != {right}" +# NOTE: +# This file is in the main source folder, so that it can be called from Julia. -def test_internal_solver( - solver: InternalSolver, - instance: Instance, - model: Any, -) -> None: - solver.set_instance(instance, model) +def run_internal_solver_tests(solver: InternalSolver) -> None: + run_basic_usage_tests(solver.clone()) + run_warm_start_tests(solver.clone()) + run_infeasibility_tests(solver.clone()) + +def run_basic_usage_tests(solver: InternalSolver) -> None: + instance = solver.build_test_instance_knapsack() + model = instance.to_model() + solver.set_instance(instance, model) assert_equals( solver.get_variable_names(), ["x[0]", "x[1]", "x[2]", "x[3]"], @@ -64,18 +66,129 @@ def test_internal_solver( assert_equals(solution["x[2]"], 1.0) assert_equals(solution["x[3]"], 1.0) - assert_equals(solver.get_constraint_ids(), ["eq_capacity"]) - assert_equals( - solver.get_constraint_rhs("eq_capacity"), - 67.0, - ) - assert_equals( - solver.get_constraint_lhs("eq_capacity"), - { - "x[0]": 23.0, - "x[1]": 26.0, - "x[2]": 20.0, - "x[3]": 18.0, - }, - ) - assert_equals(solver.get_constraint_sense("eq_capacity"), "<") + # assert_equals(solver.get_constraint_ids(), ["eq_capacity"]) + # assert_equals( + # solver.get_constraint_rhs("eq_capacity"), + # 67.0, + # ) + # assert_equals( + # solver.get_constraint_lhs("eq_capacity"), + # { + # "x[0]": 23.0, + # "x[1]": 26.0, + # "x[2]": 20.0, + # "x[3]": 18.0, + # }, + # ) + # assert_equals(solver.get_constraint_sense("eq_capacity"), "<") + + # if isinstance(solver, BasePyomoSolver): + # model.cut = pe.Constraint(expr=model.x[0] <= 0.0, name="cut") + # solver.add_constraint(model.cut) + # elif isinstance(solver, GurobiSolver): + # x = model.getVarByName("x[0]") + # solver.add_constraint(x <= 0.0, name="cut") + # else: + # raise Exception("Illegal state") + + # # Add a brand new constraint + cut = instance.build_lazy_constraint(model, "cut") + assert cut is not None + solver.add_constraint(cut, name="cut") + + # New constraint should affect solution and should be listed in + # constraint ids + assert solver.get_constraint_ids() == ["eq_capacity", "cut"] + stats = solver.solve() + assert stats["Lower bound"] == 1030.0 + + assert solver.get_sense() == "max" + assert solver.get_constraint_sense("cut") == "<" + assert solver.get_constraint_sense("eq_capacity") == "<" + + # Verify slacks + assert solver.get_inequality_slacks() == { + "cut": 0.0, + "eq_capacity": 3.0, + } + + # # Extract the new constraint + # cobj = solver.extract_constraint("cut") + # + # # New constraint should no longer affect solution and should no longer + # # be listed in constraint ids + # assert solver.get_constraint_ids() == ["eq_capacity"] + # stats = solver.solve() + # assert stats["Lower bound"] == 1183.0 + # + # # New constraint should not be satisfied by current solution + # assert not solver.is_constraint_satisfied(cobj) + # + # # Re-add constraint + # solver.add_constraint(cobj) + # + # # Constraint should affect solution again + # assert solver.get_constraint_ids() == ["eq_capacity", "cut"] + # stats = solver.solve() + # assert stats["Lower bound"] == 1030.0 + # + # # New constraint should now be satisfied + # assert solver.is_constraint_satisfied(cobj) + # + # # Relax problem and make cut into an equality constraint + # solver.relax() + # solver.set_constraint_sense("cut", "=") + # stats = solver.solve() + # assert stats["Lower bound"] is not None + # assert round(stats["Lower bound"]) == 1030.0 + # assert round(solver.get_dual("eq_capacity")) == 0.0 + + +def run_warm_start_tests(solver: InternalSolver) -> None: + instance = solver.build_test_instance_knapsack() + model = instance.to_model() + solver.set_instance(instance, model) + solver.set_warm_start({"x[0]": 1.0, "x[1]": 0.0, "x[2]": 0.0, "x[3]": 1.0}) + stats = solver.solve(tee=True) + if stats["Warm start value"] is not None: + assert_equals(stats["Warm start value"], 725.0) + + solver.set_warm_start({"x[0]": 1.0, "x[1]": 1.0, "x[2]": 1.0, "x[3]": 1.0}) + stats = solver.solve(tee=True) + assert stats["Warm start value"] is None + + solver.fix({"x[0]": 1.0, "x[1]": 0.0, "x[2]": 0.0, "x[3]": 1.0}) + stats = solver.solve(tee=True) + assert stats["Lower bound"] == 725.0 + assert stats["Upper bound"] == 725.0 + + +def run_infeasibility_tests(solver: InternalSolver) -> None: + instance = solver.build_test_instance_infeasible() + solver.set_instance(instance) + mip_stats = solver.solve() + assert solver.is_infeasible() + assert solver.get_solution() is None + assert mip_stats["Upper bound"] is None + assert mip_stats["Lower bound"] is None + lp_stats = solver.solve_lp() + assert solver.get_solution() is None + assert lp_stats["LP value"] is None + + +def run_iteration_cb_tests(solver: InternalSolver) -> None: + instance = solver.build_test_instance_knapsack() + solver.set_instance(instance) + count = 0 + + def custom_iteration_cb() -> bool: + nonlocal count + count += 1 + return count < 5 + + solver.solve(iteration_cb=custom_iteration_cb) + assert count == 5 + + +def assert_equals(left: Any, right: Any) -> None: + assert left == right, f"{left} != {right}" diff --git a/tests/__init__.py b/tests/__init__.py index f2cfff9..e69de29 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,18 +0,0 @@ -# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. -# Released under the modified BSD license. See COPYING.md for more details. -from typing import List - -import pytest - -from miplearn import InternalSolver, GurobiPyomoSolver, GurobiSolver -from miplearn.solvers.pyomo.xpress import XpressPyomoSolver - - -@pytest.fixture -def internal_solvers() -> List[InternalSolver]: - return [ - GurobiPyomoSolver(), - GurobiSolver(), - XpressPyomoSolver(), - ] diff --git a/tests/solvers/__init__.py b/tests/solvers/__init__.py index e69de29..27484ca 100644 --- a/tests/solvers/__init__.py +++ b/tests/solvers/__init__.py @@ -0,0 +1,17 @@ +# MIPLearn: Extensible Framework for Learning-Enhanced Mixed-Integer Optimization +# Copyright (C) 2020-2021, UChicago Argonne, LLC. All rights reserved. +# Released under the modified BSD license. See COPYING.md for more details. +from io import StringIO + +from miplearn.solvers import _RedirectOutput + + +def test_redirect_output() -> None: + import sys + + original_stdout = sys.stdout + io = StringIO() + with _RedirectOutput([io]): + print("Hello world") + assert sys.stdout == original_stdout + assert io.getvalue() == "Hello world\n" diff --git a/tests/solvers/test_internal_solver.py b/tests/solvers/test_internal_solver.py index baed74f..4d50a98 100644 --- a/tests/solvers/test_internal_solver.py +++ b/tests/solvers/test_internal_solver.py @@ -3,209 +3,35 @@ # Released under the modified BSD license. See COPYING.md for more details. import logging -from io import StringIO from typing import List -from warnings import warn -import pyomo.environ as pe +import pytest -from miplearn import InternalSolver -from miplearn.solvers import _RedirectOutput from miplearn.solvers.gurobi import GurobiSolver -from miplearn.solvers.pyomo.base import BasePyomoSolver - -# noinspection PyUnresolvedReferences -from .. import internal_solvers +from miplearn.solvers.internal import InternalSolver +from miplearn.solvers.pyomo.gurobi import GurobiPyomoSolver +from miplearn.solvers.pyomo.xpress import XpressPyomoSolver +from miplearn.solvers.tests import run_internal_solver_tests logger = logging.getLogger(__name__) -def test_redirect_output() -> None: - import sys - - original_stdout = sys.stdout - io = StringIO() - with _RedirectOutput([io]): - print("Hello world") - assert sys.stdout == original_stdout - assert io.getvalue() == "Hello world\n" - - -def test_internal_solver_warm_starts( - internal_solvers: List[InternalSolver], -) -> None: - for solver in internal_solvers: - logger.info("Solver: %s" % solver) - instance = solver.build_test_instance_knapsack() - model = instance.to_model() - solver.set_instance(instance, model) - solver.set_warm_start({"x[0]": 1.0, "x[1]": 0.0, "x[2]": 0.0, "x[3]": 1.0}) - stats = solver.solve(tee=True) - if stats["Warm start value"] is not None: - assert stats["Warm start value"] == 725.0 - else: - warn(f"{solver.__class__.__name__} should set warm start value") - - solver.set_warm_start({"x[0]": 1.0, "x[1]": 1.0, "x[2]": 1.0, "x[3]": 1.0}) - stats = solver.solve(tee=True) - assert stats["Warm start value"] is None - - solver.fix({"x[0]": 1.0, "x[1]": 0.0, "x[2]": 0.0, "x[3]": 1.0}) - stats = solver.solve(tee=True) - assert stats["Lower bound"] == 725.0 - assert stats["Upper bound"] == 725.0 - - -def test_internal_solver( - internal_solvers: List[InternalSolver], -) -> None: - for solver in internal_solvers: - logger.info("Solver: %s" % solver) - - instance = solver.build_test_instance_knapsack() - model = instance.to_model() - solver.set_instance(instance, model) - - assert solver.get_variable_names() == ["x[0]", "x[1]", "x[2]", "x[3]"] - - lp_stats = solver.solve_lp() - assert not solver.is_infeasible() - assert lp_stats["LP value"] is not None - assert round(lp_stats["LP value"], 3) == 1287.923 - assert len(lp_stats["LP log"]) > 100 - - solution = solver.get_solution() - assert solution is not None - assert solution["x[0]"] is not None - assert solution["x[1]"] is not None - assert solution["x[2]"] is not None - assert solution["x[3]"] is not None - assert round(solution["x[0]"], 3) == 1.000 - assert round(solution["x[1]"], 3) == 0.923 - assert round(solution["x[2]"], 3) == 1.000 - assert round(solution["x[3]"], 3) == 0.000 - - mip_stats = solver.solve(tee=True) - assert not solver.is_infeasible() - assert len(mip_stats["MIP log"]) > 100 - assert mip_stats["Lower bound"] == 1183.0 - assert mip_stats["Upper bound"] == 1183.0 - assert mip_stats["Sense"] == "max" - assert isinstance(mip_stats["Wallclock time"], float) - - solution = solver.get_solution() - assert solution is not None - assert solution["x[0]"] is not None - assert solution["x[1]"] is not None - assert solution["x[2]"] is not None - assert solution["x[3]"] is not None - assert solution["x[0]"] == 1.0 - assert solution["x[1]"] == 0.0 - assert solution["x[2]"] == 1.0 - assert solution["x[3]"] == 1.0 - - # Add a brand new constraint - if isinstance(solver, BasePyomoSolver): - model.cut = pe.Constraint(expr=model.x[0] <= 0.0, name="cut") - solver.add_constraint(model.cut) - elif isinstance(solver, GurobiSolver): - x = model.getVarByName("x[0]") - solver.add_constraint(x <= 0.0, name="cut") - else: - raise Exception("Illegal state") - - # New constraint should affect solution and should be listed in - # constraint ids - assert solver.get_constraint_ids() == ["eq_capacity", "cut"] - stats = solver.solve() - assert stats["Lower bound"] == 1030.0 - - assert solver.get_sense() == "max" - assert solver.get_constraint_sense("cut") == "<" - assert solver.get_constraint_sense("eq_capacity") == "<" - - # Verify slacks - assert solver.get_inequality_slacks() == { - "cut": 0.0, - "eq_capacity": 3.0, - } - - if isinstance(solver, GurobiSolver): - # Extract the new constraint - cobj = solver.extract_constraint("cut") - - # New constraint should no longer affect solution and should no longer - # be listed in constraint ids - assert solver.get_constraint_ids() == ["eq_capacity"] - stats = solver.solve() - assert stats["Lower bound"] == 1183.0 - - # New constraint should not be satisfied by current solution - assert not solver.is_constraint_satisfied(cobj) - - # Re-add constraint - solver.add_constraint(cobj) - - # Constraint should affect solution again - assert solver.get_constraint_ids() == ["eq_capacity", "cut"] - stats = solver.solve() - assert stats["Lower bound"] == 1030.0 - - # New constraint should now be satisfied - assert solver.is_constraint_satisfied(cobj) - - # Relax problem and make cut into an equality constraint - solver.relax() - solver.set_constraint_sense("cut", "=") - stats = solver.solve() - assert stats["Lower bound"] is not None - assert round(stats["Lower bound"]) == 1030.0 - assert round(solver.get_dual("eq_capacity")) == 0.0 - - -def test_relax( - internal_solvers: List[InternalSolver], -) -> None: - for solver in internal_solvers: - instance = solver.build_test_instance_knapsack() - solver.set_instance(instance) - solver.relax() - stats = solver.solve() - assert stats["Lower bound"] is not None - assert round(stats["Lower bound"]) == 1288.0 - - -def test_infeasible_instance( - internal_solvers: List[InternalSolver], -) -> None: - for solver in internal_solvers: - instance = solver.build_test_instance_infeasible() - solver.set_instance(instance) - mip_stats = solver.solve() +@pytest.fixture +def internal_solvers() -> List[InternalSolver]: + return [ + XpressPyomoSolver(), + GurobiSolver(), + GurobiPyomoSolver(), + ] - assert solver.is_infeasible() - assert solver.get_solution() is None - assert mip_stats["Upper bound"] is None - assert mip_stats["Lower bound"] is None - lp_stats = solver.solve_lp() - assert solver.get_solution() is None - assert lp_stats["LP value"] is None +def test_xpress_pyomo_solver() -> None: + run_internal_solver_tests(XpressPyomoSolver()) -def test_iteration_cb( - internal_solvers: List[InternalSolver], -) -> None: - for solver in internal_solvers: - logger.info("Solver: %s" % solver) - instance = solver.build_test_instance_knapsack() - solver.set_instance(instance) - count = 0 +def test_gurobi_pyomo_solver() -> None: + run_internal_solver_tests(GurobiPyomoSolver()) - def custom_iteration_cb() -> bool: - nonlocal count - count += 1 - return count < 5 - solver.solve(iteration_cb=custom_iteration_cb) - assert count == 5 +def test_gurobi_solver() -> None: + run_internal_solver_tests(GurobiSolver()) diff --git a/tests/solvers/test_lazy_cb.py b/tests/solvers/test_lazy_cb.py index 1bd38d5..7093049 100644 --- a/tests/solvers/test_lazy_cb.py +++ b/tests/solvers/test_lazy_cb.py @@ -5,8 +5,8 @@ import logging from typing import Any -from miplearn import InternalSolver from miplearn.solvers.gurobi import GurobiSolver +from miplearn.solvers.internal import InternalSolver logger = logging.getLogger(__name__) diff --git a/tests/solvers/test_learning_solver.py b/tests/solvers/test_learning_solver.py index d4f5cff..d3c68cf 100644 --- a/tests/solvers/test_learning_solver.py +++ b/tests/solvers/test_learning_solver.py @@ -9,13 +9,14 @@ from typing import List, cast import dill -from miplearn import Instance, InternalSolver +from miplearn.instance.base import Instance from miplearn.instance.picklegz import PickleGzInstance, write_pickle_gz, read_pickle_gz from miplearn.solvers.gurobi import GurobiSolver +from miplearn.solvers.internal import InternalSolver from miplearn.solvers.learning import LearningSolver # noinspection PyUnresolvedReferences -from tests import internal_solvers +from tests.solvers.test_internal_solver import internal_solvers logger = logging.getLogger(__name__)