diff --git a/src/python/miplearn/extractors.py b/src/python/miplearn/extractors.py index d4d1d1d..23ffda0 100644 --- a/src/python/miplearn/extractors.py +++ b/src/python/miplearn/extractors.py @@ -2,12 +2,12 @@ # Copyright (C) 2020, UChicago Argonne, LLC. All rights reserved. # Released under the modified BSD license. See COPYING.md for more details. -import numpy as np -from abc import ABC, abstractmethod -from pyomo.core import Var -from tqdm.auto import tqdm, trange -from p_tqdm import p_map import logging +from abc import ABC, abstractmethod + +import numpy as np +from tqdm import tqdm + logger = logging.getLogger(__name__) diff --git a/src/python/miplearn/solvers/cplex.py b/src/python/miplearn/solvers/cplex.py index 35b66d7..50d0a06 100644 --- a/src/python/miplearn/solvers/cplex.py +++ b/src/python/miplearn/solvers/cplex.py @@ -1,6 +1,7 @@ # 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 re import pyomo.environ as pe from scipy.stats import randint @@ -27,15 +28,6 @@ class CPLEXSolver(InternalSolver): for (key, value) in options.items(): self._pyomo_solver.options[key] = value - def set_threads(self, threads): - self._pyomo_solver.options["threads"] = threads - - def set_time_limit(self, time_limit): - self._pyomo_solver.options["timelimit"] = time_limit - - def set_gap_tolerance(self, gap_tolerance): - self._pyomo_solver.options["mip_tolerances_mipgap"] = gap_tolerance - def solve_lp(self, tee=False): import cplex lp = self._pyomo_solver._solver_model @@ -48,14 +40,31 @@ class CPLEXSolver(InternalSolver): "Optimal value": results["Problem"][0]["Lower bound"], } + def set_threads(self, threads): + self._pyomo_solver.options["threads"] = threads + + def set_time_limit(self, time_limit): + self._pyomo_solver.options["timelimit"] = time_limit + + def set_gap_tolerance(self, gap_tolerance): + self._pyomo_solver.options["mip_tolerances_mipgap"] = gap_tolerance + def _get_warm_start_regexp(self): return "MIP start .* with objective ([0-9.e+-]*)\\." + def _get_node_count_regexp(self): + return "^[ *] *([0-9]+)" + def _get_threads_option_name(self): return "threads" def _get_time_limit_option_name(self): return "timelimit" + def _get_node_limit_option_name(self): + return "mip_limits_nodes" + def _get_gap_tolerance_option_name(self): return "mip_gap_tolerances_mipgap" + + diff --git a/src/python/miplearn/solvers/gurobi.py b/src/python/miplearn/solvers/gurobi.py index 7c61778..139b767 100644 --- a/src/python/miplearn/solvers/gurobi.py +++ b/src/python/miplearn/solvers/gurobi.py @@ -16,7 +16,7 @@ logger = logging.getLogger(__name__) class GurobiSolver(InternalSolver): def __init__(self, - use_lazy_callbacks=False, + use_lazy_callbacks=True, options=None): """ Creates a new GurobiSolver. @@ -70,26 +70,35 @@ class GurobiSolver(InternalSolver): results = self._pyomo_solver.solve(tee=True, warmstart=self._is_warm_start_available) self._pyomo_solver.set_callback(None) - node_count = int(self._pyomo_solver._solver_model.getAttr("NodeCount")) log = streams[0].getvalue() return { "Lower bound": results["Problem"][0]["Lower bound"], "Upper bound": results["Problem"][0]["Upper bound"], "Wallclock time": results["Solver"][0]["Wallclock time"], - "Nodes": max(1, node_count), + "Nodes": self._extract_node_count(log), "Sense": self._obj_sense, "Log": log, - "Warm start value": self.extract_warm_start_value(log), + "Warm start value": self._extract_warm_start_value(log), } + def _extract_node_count(self, log): + return max(1, int(self._pyomo_solver._solver_model.getAttr("NodeCount"))) + def _get_warm_start_regexp(self): return "MIP start with objective ([0-9.e+-]*)" + def _get_node_count_regexp(self): + return None + def _get_threads_option_name(self): return "Threads" def _get_time_limit_option_name(self): return "TimeLimit" + def _get_node_limit_option_name(self): + return "NodeLimit" + def _get_gap_tolerance_option_name(self): return "MIPGap" + diff --git a/src/python/miplearn/solvers/internal.py b/src/python/miplearn/solvers/internal.py index 4b62b94..085cd79 100644 --- a/src/python/miplearn/solvers/internal.py +++ b/src/python/miplearn/solvers/internal.py @@ -208,6 +208,7 @@ class InternalSolver(ABC): with RedirectOutput(streams): results = self._pyomo_solver.solve(tee=True, warmstart=self._is_warm_start_available) + print(results) total_wallclock_time += results["Solver"][0]["Wallclock time"] if not hasattr(self.instance, "find_violations"): break @@ -226,25 +227,40 @@ class InternalSolver(ABC): "Lower bound": results["Problem"][0]["Lower bound"], "Upper bound": results["Problem"][0]["Upper bound"], "Wallclock time": total_wallclock_time, - "Nodes": 1, + "Nodes": self._extract_node_count(log), "Sense": self._obj_sense, "Log": log, - "Warm start value": self.extract_warm_start_value(log), + "Warm start value": self._extract_warm_start_value(log), } - def extract_warm_start_value(self, log): + @staticmethod + def __extract(log, regexp, default=None): + value = default + for line in log.splitlines(): + matches = re.findall(regexp, line) + if len(matches) == 0: + continue + value = matches[0] + return value + + def _extract_warm_start_value(self, log): """ Extracts and returns the objective value of the user-provided MIP start from the provided solver log. If more than one value is found, returns the last one. If no value is present in the logs, returns None. """ - ws_value = None - for line in log.splitlines(): - matches = re.findall(self._get_warm_start_regexp(), line) - if len(matches) == 0: - continue - ws_value = float(matches[0]) - return ws_value + value = self.__extract(log, self._get_warm_start_regexp()) + if value is not None: + value = float(value) + return value + + def _extract_node_count(self, log): + """ + Extracts and returns the number of explored branch-and-bound nodes. + """ + return int(self.__extract(log, + self._get_node_count_regexp(), + default=1)) def set_threads(self, threads): key = self._get_threads_option_name() @@ -254,6 +270,10 @@ class InternalSolver(ABC): key = self._get_time_limit_option_name() self._pyomo_solver.options[key] = time_limit + def set_node_limit(self, node_limit): + key = self._get_node_limit_option_name() + self._pyomo_solver.options[key] = node_limit + def set_gap_tolerance(self, gap_tolerance): key = self._get_gap_tolerance_option_name() self._pyomo_solver.options[key] = gap_tolerance @@ -262,6 +282,10 @@ class InternalSolver(ABC): def _get_warm_start_regexp(self): pass + @abstractmethod + def _get_node_count_regexp(self): + pass + @abstractmethod def _get_threads_option_name(self): pass @@ -270,8 +294,13 @@ class InternalSolver(ABC): def _get_time_limit_option_name(self): pass + @abstractmethod + def _get_node_limit_option_name(self): + pass + @abstractmethod def _get_gap_tolerance_option_name(self): pass + diff --git a/src/python/miplearn/solvers/tests/test_internal_solver.py b/src/python/miplearn/solvers/tests/test_internal_solver.py index fd4bb67..1ee0d12 100644 --- a/src/python/miplearn/solvers/tests/test_internal_solver.py +++ b/src/python/miplearn/solvers/tests/test_internal_solver.py @@ -7,6 +7,7 @@ from miplearn.solvers.cplex import CPLEXSolver from miplearn.solvers.gurobi import GurobiSolver from . import _get_instance +from ...problems.knapsack import ChallengeA def test_internal_solver_warm_starts(): @@ -72,3 +73,13 @@ def test_internal_solver(): solver.solve_lp() assert model.x[0].value == 0.5 + +def test_node_count(): + for solver in [GurobiSolver(), + GurobiSolver(use_lazy_callbacks=False), + CPLEXSolver()]: + challenge = ChallengeA() + solver.set_time_limit(1) + solver.set_instance(challenge.test_instances[0]) + stats = solver.solve(tee=True) + assert stats["Nodes"] > 1 \ No newline at end of file