From c34fed846c4ca0ead871c68c6b7ab7c7e413cbb0 Mon Sep 17 00:00:00 2001 From: Alinson S Xavier Date: Sat, 11 Apr 2020 19:54:12 -0500 Subject: [PATCH] Split solvers.py into multiple files --- src/python/miplearn/__init__.py | 15 +- src/python/miplearn/benchmark.py | 5 +- src/python/miplearn/problems/tsp.py | 6 +- src/python/miplearn/solvers.py | 593 ------------------ src/python/miplearn/solvers/__init__.py | 32 + src/python/miplearn/solvers/cplex.py | 61 ++ src/python/miplearn/solvers/gurobi.py | 95 +++ src/python/miplearn/solvers/internal.py | 277 ++++++++ src/python/miplearn/solvers/learning.py | 169 +++++ src/python/miplearn/solvers/tests/__init__.py | 4 + .../{ => solvers}/tests/test_solver.py | 6 +- src/python/miplearn/tests/test_benchmark.py | 5 +- 12 files changed, 661 insertions(+), 607 deletions(-) delete mode 100644 src/python/miplearn/solvers.py create mode 100644 src/python/miplearn/solvers/__init__.py create mode 100644 src/python/miplearn/solvers/cplex.py create mode 100644 src/python/miplearn/solvers/gurobi.py create mode 100644 src/python/miplearn/solvers/internal.py create mode 100644 src/python/miplearn/solvers/learning.py create mode 100644 src/python/miplearn/solvers/tests/__init__.py rename src/python/miplearn/{ => solvers}/tests/test_solver.py (96%) diff --git a/src/python/miplearn/__init__.py b/src/python/miplearn/__init__.py index 1c438aa..9a80274 100644 --- a/src/python/miplearn/__init__.py +++ b/src/python/miplearn/__init__.py @@ -5,15 +5,20 @@ from .extractors import (SolutionExtractor, InstanceFeaturesExtractor, ObjectiveValueExtractor, - VariableFeaturesExtractor, - ) + VariableFeaturesExtractor) + from .components.component import Component from .components.objective import ObjectiveValueComponent from .components.lazy import LazyConstraintsComponent from .components.primal import (PrimalSolutionComponent, - AdaptivePredictor, - ) + AdaptivePredictor) from .components.branching import BranchPriorityComponent + from .benchmark import BenchmarkRunner + from .instance import Instance -from .solvers import LearningSolver + +from .solvers.learning import LearningSolver +from .solvers.cplex import CPLEXSolver +from .solvers.gurobi import GurobiSolver +from .solvers.internal import InternalSolver diff --git a/src/python/miplearn/benchmark.py b/src/python/miplearn/benchmark.py index aae748c..28230fe 100644 --- a/src/python/miplearn/benchmark.py +++ b/src/python/miplearn/benchmark.py @@ -2,11 +2,14 @@ # Copyright (C) 2020, UChicago Argonne, LLC. All rights reserved. # Released under the modified BSD license. See COPYING.md for more details. -from .solvers import LearningSolver from copy import deepcopy + import pandas as pd from tqdm.auto import tqdm +from .solvers.learning import LearningSolver + + class BenchmarkRunner: def __init__(self, solvers): assert isinstance(solvers, dict) diff --git a/src/python/miplearn/problems/tsp.py b/src/python/miplearn/problems/tsp.py index e7fae64..68b185e 100644 --- a/src/python/miplearn/problems/tsp.py +++ b/src/python/miplearn/problems/tsp.py @@ -1,6 +1,6 @@ -# MIPLearn, an extensible framework for Learning-Enhanced Mixed-Integer Optimization -# Copyright (C) 2019-2020 Argonne National Laboratory. All rights reserved. -# Written by Alinson S. Xavier +# 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 numpy as np import pyomo.environ as pe diff --git a/src/python/miplearn/solvers.py b/src/python/miplearn/solvers.py deleted file mode 100644 index 49ca8ce..0000000 --- a/src/python/miplearn/solvers.py +++ /dev/null @@ -1,593 +0,0 @@ -# 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 logging -import re -import sys -from abc import ABC, abstractmethod -from copy import deepcopy -from io import StringIO -from typing import Optional, List - -import pyomo.core.kernel.objective -import pyomo.environ as pe -from p_tqdm import p_map -from pyomo.core import Var -from scipy.stats import randint - -from . import (ObjectiveValueComponent, - PrimalSolutionComponent, - LazyConstraintsComponent) -from .instance import Instance - -logger = logging.getLogger(__name__) - - -# Global memory for multiprocessing -SOLVER = [None] # type: List[Optional[LearningSolver]] -INSTANCES = [None] # type: List[Optional[dict]] - - -def _parallel_solve(instance_idx): - solver = deepcopy(SOLVER[0]) - instance = INSTANCES[0][instance_idx] - results = solver.solve(instance) - return { - "Results": results, - "Solution": instance.solution, - "LP solution": instance.lp_solution, - "LP value": instance.lp_value, - "Upper bound": instance.upper_bound, - "Lower bound": instance.lower_bound, - "Violations": instance.found_violations, - } - - -class RedirectOutput(object): - def __init__(self, streams): - self.streams = streams - self._original_stdout = sys.stdout - self._original_stderr = sys.stderr - sys.stdout = self - sys.stderr = self - - def __del__(self): - sys.stdout = self._original_stdout - sys.stderr = self._original_stderr - - def write(self, data): - for stream in self.streams: - stream.write(data) - - def flush(self): - for stream in self.streams: - stream.flush() - - def __enter__(self): - pass - - def __exit__(self, _type, _value, _traceback): - pass - - -class InternalSolver(ABC): - """ - The MIP solver used internaly by LearningSolver. - - Attributes - ---------- - instance: miplearn.Instance - The MIPLearn instance currently loaded to the solver - model: pyomo.core.ConcreteModel - The Pyomo model currently loaded on the solver - """ - - def __init__(self): - self.instance = None - self.model = None - self._all_vars = None - self._bin_vars = None - self._is_warm_start_available = False - self._pyomo_solver = None - self._obj_sense = None - self._varname_to_var = {} - - def solve_lp(self, tee=False): - """ - Solves the LP relaxation of the currently loaded instance. - - Parameters - ---------- - tee: bool - If true, prints the solver log to the screen. - - Returns - ------- - dict - A dictionary of solver statistics containing the following keys: - "Optimal value". - """ - for var in self._bin_vars: - lb, ub = var.bounds - var.setlb(lb) - var.setub(ub) - var.domain = pyomo.core.base.set_types.Reals - self._pyomo_solver.update_var(var) - results = self._pyomo_solver.solve(tee=tee) - for var in self._bin_vars: - var.domain = pyomo.core.base.set_types.Binary - self._pyomo_solver.update_var(var) - return { - "Optimal value": results["Problem"][0]["Lower bound"], - } - - def get_solution(self): - """ - Returns current solution found by the solver. - - If called after `solve`, returns the best primal solution found during - the search. If called after `solve_lp`, returns the optimal solution - to the LP relaxation. - - The solution is a dictionary `sol`, where the optimal value of `var[idx]` - is given by `sol[var][idx]`. - """ - solution = {} - for var in self.model.component_objects(Var): - solution[str(var)] = {} - for index in var: - solution[str(var)][index] = var[index].value - return solution - - def set_warm_start(self, solution): - """ - Sets the warm start to be used by the solver. - - The solution should be a dictionary following the same format as the - one produced by `get_solution`. Only one warm start is currently - supported. Calling this function when a warm start already exists will - remove the previous warm start. - """ - self.clear_warm_start() - count_total, count_fixed = 0, 0 - for var_name in solution: - var = self._varname_to_var[var_name] - for index in solution[var_name]: - count_total += 1 - var[index].value = solution[var_name][index] - if solution[var_name][index] is not None: - count_fixed += 1 - if count_fixed > 0: - self._is_warm_start_available = True - logger.info("Setting start values for %d variables (out of %d)" % - (count_fixed, count_total)) - - def clear_warm_start(self): - """ - Removes any existing warm start from the solver. - """ - for var in self._all_vars: - if not var.fixed: - var.value = None - self._is_warm_start_available = False - - def set_instance(self, instance, model=None): - """ - Loads the given instance into the solver. - - Parameters - ---------- - instance: miplearn.Instance - The instance to be loaded. - model: pyomo.core.ConcreteModel - The corresponding Pyomo model. If not provided, it will be - generated by calling `instance.to_model()`. - """ - if model is None: - model = instance.to_model() - assert isinstance(instance, Instance) - assert isinstance(model, pe.ConcreteModel) - self.instance = instance - self.model = model - self._pyomo_solver.set_instance(model) - - # Update objective sense - self._obj_sense = "max" - if self._pyomo_solver._objective.sense == pyomo.core.kernel.objective.minimize: - self._obj_sense = "min" - - # Update variables - self._all_vars = [] - self._bin_vars = [] - self._varname_to_var = {} - for var in model.component_objects(Var): - self._varname_to_var[var.name] = var - for idx in var: - self._all_vars += [var[idx]] - if var[idx].domain == pyomo.core.base.set_types.Binary: - self._bin_vars += [var[idx]] - - def fix(self, solution): - """ - Fixes the values of a subset of decision variables. - - The values should be provided in the dictionary format generated by - `get_solution`. Missing values in the solution indicate variables - that should be left free. - """ - count_total, count_fixed = 0, 0 - for varname in solution: - for index in solution[varname]: - var = self._varname_to_var[varname] - count_total += 1 - if solution[varname][index] is None: - continue - count_fixed += 1 - var[index].fix(solution[varname][index]) - self._pyomo_solver.update_var(var[index]) - logger.info("Fixing values for %d variables (out of %d)" % - (count_fixed, count_total)) - - def add_constraint(self, constraint): - """ - Adds a single constraint to the model. - """ - self._pyomo_solver.add_constraint(constraint) - - def solve(self, tee=False): - """ - Solves the currently loaded instance. - - Parameters - ---------- - tee: bool - If true, prints the solver log to the screen. - - Returns - ------- - dict - A dictionary of solver statistics containing the following keys: - "Lower bound", "Upper bound", "Wallclock time", "Nodes", "Sense", - "Log" and "Warm start value". - """ - total_wallclock_time = 0 - streams = [StringIO()] - if tee: - streams += [sys.stdout] - self.instance.found_violations = [] - while True: - logger.debug("Solving MIP...") - with RedirectOutput(streams): - results = self._pyomo_solver.solve(tee=True, - warmstart=self._is_warm_start_available) - total_wallclock_time += results["Solver"][0]["Wallclock time"] - if not hasattr(self.instance, "find_violations"): - break - logger.debug("Finding violated constraints...") - violations = self.instance.find_violations(self.model) - if len(violations) == 0: - break - self.instance.found_violations += violations - logger.debug(" %d violations found" % len(violations)) - for v in violations: - cut = self.instance.build_lazy_constraint(self.model, v) - self.add_constraint(cut) - - log = streams[0].getvalue() - return { - "Lower bound": results["Problem"][0]["Lower bound"], - "Upper bound": results["Problem"][0]["Upper bound"], - "Wallclock time": total_wallclock_time, - "Nodes": 1, - "Sense": self._obj_sense, - "Log": log, - "Warm start value": self.extract_warm_start_value(log), - } - - 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 - - def set_threads(self, threads): - key = self._get_threads_option_name() - self._pyomo_solver.options[key] = threads - - def set_time_limit(self, time_limit): - key = self._get_time_limit_option_name() - self._pyomo_solver.options[key] = time_limit - - def set_gap_tolerance(self, gap_tolerance): - key = self._get_gap_tolerance_option_name() - self._pyomo_solver.options[key] = gap_tolerance - - @abstractmethod - def _get_warm_start_regexp(self): - pass - - @abstractmethod - def _get_threads_option_name(self): - pass - - @abstractmethod - def _get_time_limit_option_name(self): - pass - - @abstractmethod - def _get_gap_tolerance_option_name(self): - pass - - -class GurobiSolver(InternalSolver): - def __init__(self, - use_lazy_callbacks=False, - options=None): - """ - Creates a new GurobiSolver. - - Parameters - ---------- - use_lazy_callbacks: bool - If true, lazy constraints will be enforced via lazy callbacks. - Otherwise, they will be enforced via a simple solve-check loop. - options: dict - Dictionary of options to pass to the Pyomo solver. For example, - {"Threads": 4} to set the number of threads. - """ - super().__init__() - self._use_lazy_callbacks = use_lazy_callbacks - self._pyomo_solver = pe.SolverFactory('gurobi_persistent') - self._pyomo_solver.options["Seed"] = randint(low=0, high=1000).rvs() - if options is not None: - for (key, value) in options.items(): - self._pyomo_solver.options[key] = value - - def solve(self, tee=False): - if self._use_lazy_callbacks: - return self._solve_with_callbacks(tee) - else: - return super().solve(tee) - - def _solve_with_callbacks(self, tee): - from gurobipy import GRB - - def cb(cb_model, cb_opt, cb_where): - if cb_where == GRB.Callback.MIPSOL: - cb_opt.cbGetSolution(self._all_vars) - logger.debug("Finding violated constraints...") - violations = self.instance.find_violations(cb_model) - self.instance.found_violations += violations - logger.debug(" %d violations found" % len(violations)) - for v in violations: - cut = self.instance.build_lazy_constraint(cb_model, v) - cb_opt.cbLazy(cut) - - if hasattr(self.instance, "find_violations"): - self._pyomo_solver.options["LazyConstraints"] = 1 - self._pyomo_solver.set_callback(cb) - self.instance.found_violations = [] - print(self._is_warm_start_available) - streams = [StringIO()] - if tee: - streams += [sys.stdout] - with RedirectOutput(streams): - 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), - "Sense": self._obj_sense, - "Log": log, - "Warm start value": self.extract_warm_start_value(log), - } - - def _get_warm_start_regexp(self): - return "MIP start with objective ([0-9.e+-]*)" - - def _get_threads_option_name(self): - return "Threads" - - def _get_time_limit_option_name(self): - return "TimeLimit" - - def _get_gap_tolerance_option_name(self): - return "MIPGap" - - -class CPLEXSolver(InternalSolver): - def __init__(self, options=None): - """ - Creates a new CPLEXSolver. - - Parameters - ---------- - options: dict - Dictionary of options to pass to the Pyomo solver. For example, - {"mip_display": 5} to increase the log verbosity. - """ - super().__init__() - self._pyomo_solver = pe.SolverFactory('cplex_persistent') - self._pyomo_solver.options["randomseed"] = randint(low=0, high=1000).rvs() - self._pyomo_solver.options["mip_display"] = 4 - if options is not None: - 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 - var_types = lp.variables.get_types() - n_vars = len(var_types) - lp.set_problem_type(cplex.Cplex.problem_type.LP) - results = self._pyomo_solver.solve(tee=tee) - lp.variables.set_types(zip(range(n_vars), var_types)) - return { - "Optimal value": results["Problem"][0]["Lower bound"], - } - - def _get_warm_start_regexp(self): - return "MIP start .* with objective ([0-9.e+-]*)\\." - - def _get_threads_option_name(self): - return "threads" - - def _get_time_limit_option_name(self): - return "timelimit" - - def _get_gap_tolerance_option_name(self): - return "mip_gap_tolerances_mipgap" - - -class LearningSolver: - """ - Mixed-Integer Linear Programming (MIP) solver that extracts information - from previous runs, using Machine Learning methods, to accelerate the - solution of new (yet unseen) instances. - """ - - def __init__(self, - components=None, - gap_tolerance=None, - mode="exact", - solver="gurobi", - threads=4, - time_limit=None): - - self.components = {} - self.mode = mode - self.internal_solver = None - self.internal_solver_factory = solver - self.threads = threads - self.time_limit = time_limit - self.gap_tolerance = gap_tolerance - self.tee = False - - if components is not None: - for comp in components: - self.add(comp) - else: - self.add(ObjectiveValueComponent()) - self.add(PrimalSolutionComponent()) - self.add(LazyConstraintsComponent()) - - assert self.mode in ["exact", "heuristic"] - for component in self.components.values(): - component.mode = self.mode - - def _create_internal_solver(self): - logger.debug("Initializing %s" % self.internal_solver_factory) - if self.internal_solver_factory == "cplex": - solver = CPLEXSolver() - elif self.internal_solver_factory == "gurobi": - solver = GurobiSolver() - elif callable(self.internal_solver_factory): - solver = self.internal_solver_factory() - assert isinstance(solver, InternalSolver) - else: - raise Exception("solver %s not supported" % self.internal_solver_factory) - solver.set_threads(self.threads) - if self.time_limit is not None: - solver.set_time_limit(self.time_limit) - if self.gap_tolerance is not None: - solver.set_gap_tolerance(self.gap_tolerance) - return solver - - def solve(self, - instance, - model=None, - tee=False, - relaxation_only=False): - - if model is None: - model = instance.to_model() - - self.tee = tee - self.internal_solver = self._create_internal_solver() - self.internal_solver.set_instance(instance, model=model) - - logger.debug("Solving LP relaxation...") - results = self.internal_solver.solve_lp(tee=tee) - instance.lp_solution = self.internal_solver.get_solution() - instance.lp_value = results["Optimal value"] - - logger.debug("Running before_solve callbacks...") - for component in self.components.values(): - component.before_solve(self, instance, model) - - if relaxation_only: - return results - - results = self.internal_solver.solve(tee=tee) - - # Read MIP solution and bounds - instance.lower_bound = results["Lower bound"] - instance.upper_bound = results["Upper bound"] - instance.solution = self.internal_solver.get_solution() - - logger.debug("Calling after_solve callbacks...") - for component in self.components.values(): - component.after_solve(self, instance, model, results) - - return results - - def parallel_solve(self, - instances, - n_jobs=4, - label="Solve"): - - self.internal_solver = None - SOLVER[0] = self - INSTANCES[0] = instances - p_map_results = p_map(_parallel_solve, - list(range(len(instances))), - num_cpus=n_jobs, - desc=label) - - results = [p["Results"] for p in p_map_results] - for (idx, r) in enumerate(p_map_results): - instances[idx].solution = r["Solution"] - instances[idx].lp_solution = r["LP solution"] - instances[idx].lp_value = r["LP value"] - instances[idx].lower_bound = r["Lower bound"] - instances[idx].upper_bound = r["Upper bound"] - instances[idx].found_violations = r["Violations"] - - return results - - def fit(self, training_instances): - if len(training_instances) == 0: - return - for component in self.components.values(): - component.fit(training_instances) - - def add(self, component): - name = component.__class__.__name__ - self.components[name] = component - - def __getstate__(self): - self.internal_solver = None - return self.__dict__ diff --git a/src/python/miplearn/solvers/__init__.py b/src/python/miplearn/solvers/__init__.py new file mode 100644 index 0000000..93ee6d0 --- /dev/null +++ b/src/python/miplearn/solvers/__init__.py @@ -0,0 +1,32 @@ +# 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 + + +class RedirectOutput(object): + def __init__(self, streams): + self.streams = streams + self._original_stdout = sys.stdout + self._original_stderr = sys.stderr + sys.stdout = self + sys.stderr = self + + def __del__(self): + sys.stdout = self._original_stdout + sys.stderr = self._original_stderr + + def write(self, data): + for stream in self.streams: + stream.write(data) + + def flush(self): + for stream in self.streams: + stream.flush() + + def __enter__(self): + pass + + def __exit__(self, _type, _value, _traceback): + pass diff --git a/src/python/miplearn/solvers/cplex.py b/src/python/miplearn/solvers/cplex.py new file mode 100644 index 0000000..35b66d7 --- /dev/null +++ b/src/python/miplearn/solvers/cplex.py @@ -0,0 +1,61 @@ +# 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 pyomo.environ as pe +from scipy.stats import randint + +from .internal import InternalSolver + + +class CPLEXSolver(InternalSolver): + def __init__(self, options=None): + """ + Creates a new CPLEXSolver. + + Parameters + ---------- + options: dict + Dictionary of options to pass to the Pyomo solver. For example, + {"mip_display": 5} to increase the log verbosity. + """ + super().__init__() + self._pyomo_solver = pe.SolverFactory('cplex_persistent') + self._pyomo_solver.options["randomseed"] = randint(low=0, high=1000).rvs() + self._pyomo_solver.options["mip_display"] = 4 + if options is not None: + 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 + var_types = lp.variables.get_types() + n_vars = len(var_types) + lp.set_problem_type(cplex.Cplex.problem_type.LP) + results = self._pyomo_solver.solve(tee=tee) + lp.variables.set_types(zip(range(n_vars), var_types)) + return { + "Optimal value": results["Problem"][0]["Lower bound"], + } + + def _get_warm_start_regexp(self): + return "MIP start .* with objective ([0-9.e+-]*)\\." + + def _get_threads_option_name(self): + return "threads" + + def _get_time_limit_option_name(self): + return "timelimit" + + 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 new file mode 100644 index 0000000..7c61778 --- /dev/null +++ b/src/python/miplearn/solvers/gurobi.py @@ -0,0 +1,95 @@ +# 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 logging +import sys +from io import StringIO + +import pyomo.environ as pe +from miplearn.solvers import RedirectOutput +from miplearn.solvers.internal import InternalSolver +from scipy.stats import randint + +logger = logging.getLogger(__name__) + + +class GurobiSolver(InternalSolver): + def __init__(self, + use_lazy_callbacks=False, + options=None): + """ + Creates a new GurobiSolver. + + Parameters + ---------- + use_lazy_callbacks: bool + If true, lazy constraints will be enforced via lazy callbacks. + Otherwise, they will be enforced via a simple solve-check loop. + options: dict + Dictionary of options to pass to the Pyomo solver. For example, + {"Threads": 4} to set the number of threads. + """ + super().__init__() + self._use_lazy_callbacks = use_lazy_callbacks + self._pyomo_solver = pe.SolverFactory('gurobi_persistent') + self._pyomo_solver.options["Seed"] = randint(low=0, high=1000).rvs() + if options is not None: + for (key, value) in options.items(): + self._pyomo_solver.options[key] = value + + def solve(self, tee=False): + if self._use_lazy_callbacks: + return self._solve_with_callbacks(tee) + else: + return super().solve(tee) + + def _solve_with_callbacks(self, tee): + from gurobipy import GRB + + def cb(cb_model, cb_opt, cb_where): + if cb_where == GRB.Callback.MIPSOL: + cb_opt.cbGetSolution(self._all_vars) + logger.debug("Finding violated constraints...") + violations = self.instance.find_violations(cb_model) + self.instance.found_violations += violations + logger.debug(" %d violations found" % len(violations)) + for v in violations: + cut = self.instance.build_lazy_constraint(cb_model, v) + cb_opt.cbLazy(cut) + + if hasattr(self.instance, "find_violations"): + self._pyomo_solver.options["LazyConstraints"] = 1 + self._pyomo_solver.set_callback(cb) + self.instance.found_violations = [] + print(self._is_warm_start_available) + streams = [StringIO()] + if tee: + streams += [sys.stdout] + with RedirectOutput(streams): + 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), + "Sense": self._obj_sense, + "Log": log, + "Warm start value": self.extract_warm_start_value(log), + } + + def _get_warm_start_regexp(self): + return "MIP start with objective ([0-9.e+-]*)" + + def _get_threads_option_name(self): + return "Threads" + + def _get_time_limit_option_name(self): + return "TimeLimit" + + 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 new file mode 100644 index 0000000..4b62b94 --- /dev/null +++ b/src/python/miplearn/solvers/internal.py @@ -0,0 +1,277 @@ +# 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 logging +import re +import sys +from abc import ABC, abstractmethod +from io import StringIO + +import pyomo +import pyomo.environ as pe +from pyomo.core import Var + +from . import RedirectOutput +from ..instance import Instance + +logger = logging.getLogger(__name__) + + +class InternalSolver(ABC): + """ + The MIP solver used internaly by LearningSolver. + + Attributes + ---------- + instance: miplearn.Instance + The MIPLearn instance currently loaded to the solver + model: pyomo.core.ConcreteModel + The Pyomo model currently loaded on the solver + """ + + def __init__(self): + self.instance = None + self.model = None + self._all_vars = None + self._bin_vars = None + self._is_warm_start_available = False + self._pyomo_solver = None + self._obj_sense = None + self._varname_to_var = {} + + def solve_lp(self, tee=False): + """ + Solves the LP relaxation of the currently loaded instance. + + Parameters + ---------- + tee: bool + If true, prints the solver log to the screen. + + Returns + ------- + dict + A dictionary of solver statistics containing the following keys: + "Optimal value". + """ + for var in self._bin_vars: + lb, ub = var.bounds + var.setlb(lb) + var.setub(ub) + var.domain = pyomo.core.base.set_types.Reals + self._pyomo_solver.update_var(var) + results = self._pyomo_solver.solve(tee=tee) + for var in self._bin_vars: + var.domain = pyomo.core.base.set_types.Binary + self._pyomo_solver.update_var(var) + return { + "Optimal value": results["Problem"][0]["Lower bound"], + } + + def get_solution(self): + """ + Returns current solution found by the solver. + + If called after `solve`, returns the best primal solution found during + the search. If called after `solve_lp`, returns the optimal solution + to the LP relaxation. + + The solution is a dictionary `sol`, where the optimal value of `var[idx]` + is given by `sol[var][idx]`. + """ + solution = {} + for var in self.model.component_objects(Var): + solution[str(var)] = {} + for index in var: + solution[str(var)][index] = var[index].value + return solution + + def set_warm_start(self, solution): + """ + Sets the warm start to be used by the solver. + + The solution should be a dictionary following the same format as the + one produced by `get_solution`. Only one warm start is currently + supported. Calling this function when a warm start already exists will + remove the previous warm start. + """ + self.clear_warm_start() + count_total, count_fixed = 0, 0 + for var_name in solution: + var = self._varname_to_var[var_name] + for index in solution[var_name]: + count_total += 1 + var[index].value = solution[var_name][index] + if solution[var_name][index] is not None: + count_fixed += 1 + if count_fixed > 0: + self._is_warm_start_available = True + logger.info("Setting start values for %d variables (out of %d)" % + (count_fixed, count_total)) + + def clear_warm_start(self): + """ + Removes any existing warm start from the solver. + """ + for var in self._all_vars: + if not var.fixed: + var.value = None + self._is_warm_start_available = False + + def set_instance(self, instance, model=None): + """ + Loads the given instance into the solver. + + Parameters + ---------- + instance: miplearn.Instance + The instance to be loaded. + model: pyomo.core.ConcreteModel + The corresponding Pyomo model. If not provided, it will be + generated by calling `instance.to_model()`. + """ + if model is None: + model = instance.to_model() + assert isinstance(instance, Instance) + assert isinstance(model, pe.ConcreteModel) + self.instance = instance + self.model = model + self._pyomo_solver.set_instance(model) + + # Update objective sense + self._obj_sense = "max" + if self._pyomo_solver._objective.sense == pyomo.core.kernel.objective.minimize: + self._obj_sense = "min" + + # Update variables + self._all_vars = [] + self._bin_vars = [] + self._varname_to_var = {} + for var in model.component_objects(Var): + self._varname_to_var[var.name] = var + for idx in var: + self._all_vars += [var[idx]] + if var[idx].domain == pyomo.core.base.set_types.Binary: + self._bin_vars += [var[idx]] + + def fix(self, solution): + """ + Fixes the values of a subset of decision variables. + + The values should be provided in the dictionary format generated by + `get_solution`. Missing values in the solution indicate variables + that should be left free. + """ + count_total, count_fixed = 0, 0 + for varname in solution: + for index in solution[varname]: + var = self._varname_to_var[varname] + count_total += 1 + if solution[varname][index] is None: + continue + count_fixed += 1 + var[index].fix(solution[varname][index]) + self._pyomo_solver.update_var(var[index]) + logger.info("Fixing values for %d variables (out of %d)" % + (count_fixed, count_total)) + + def add_constraint(self, constraint): + """ + Adds a single constraint to the model. + """ + self._pyomo_solver.add_constraint(constraint) + + def solve(self, tee=False): + """ + Solves the currently loaded instance. + + Parameters + ---------- + tee: bool + If true, prints the solver log to the screen. + + Returns + ------- + dict + A dictionary of solver statistics containing the following keys: + "Lower bound", "Upper bound", "Wallclock time", "Nodes", "Sense", + "Log" and "Warm start value". + """ + total_wallclock_time = 0 + streams = [StringIO()] + if tee: + streams += [sys.stdout] + self.instance.found_violations = [] + while True: + logger.debug("Solving MIP...") + with RedirectOutput(streams): + results = self._pyomo_solver.solve(tee=True, + warmstart=self._is_warm_start_available) + total_wallclock_time += results["Solver"][0]["Wallclock time"] + if not hasattr(self.instance, "find_violations"): + break + logger.debug("Finding violated constraints...") + violations = self.instance.find_violations(self.model) + if len(violations) == 0: + break + self.instance.found_violations += violations + logger.debug(" %d violations found" % len(violations)) + for v in violations: + cut = self.instance.build_lazy_constraint(self.model, v) + self.add_constraint(cut) + + log = streams[0].getvalue() + return { + "Lower bound": results["Problem"][0]["Lower bound"], + "Upper bound": results["Problem"][0]["Upper bound"], + "Wallclock time": total_wallclock_time, + "Nodes": 1, + "Sense": self._obj_sense, + "Log": log, + "Warm start value": self.extract_warm_start_value(log), + } + + 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 + + def set_threads(self, threads): + key = self._get_threads_option_name() + self._pyomo_solver.options[key] = threads + + def set_time_limit(self, time_limit): + key = self._get_time_limit_option_name() + self._pyomo_solver.options[key] = time_limit + + def set_gap_tolerance(self, gap_tolerance): + key = self._get_gap_tolerance_option_name() + self._pyomo_solver.options[key] = gap_tolerance + + @abstractmethod + def _get_warm_start_regexp(self): + pass + + @abstractmethod + def _get_threads_option_name(self): + pass + + @abstractmethod + def _get_time_limit_option_name(self): + pass + + @abstractmethod + def _get_gap_tolerance_option_name(self): + pass + + diff --git a/src/python/miplearn/solvers/learning.py b/src/python/miplearn/solvers/learning.py new file mode 100644 index 0000000..a3f756d --- /dev/null +++ b/src/python/miplearn/solvers/learning.py @@ -0,0 +1,169 @@ +# 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 logging +from copy import deepcopy +from typing import Optional, List + +from p_tqdm import p_map + +from .cplex import CPLEXSolver +from .gurobi import GurobiSolver +from .internal import InternalSolver +from .. import (ObjectiveValueComponent, + PrimalSolutionComponent, + LazyConstraintsComponent) + +logger = logging.getLogger(__name__) + + +# Global memory for multiprocessing +SOLVER = [None] # type: List[Optional[LearningSolver]] +INSTANCES = [None] # type: List[Optional[dict]] + + +def _parallel_solve(instance_idx): + solver = deepcopy(SOLVER[0]) + instance = INSTANCES[0][instance_idx] + results = solver.solve(instance) + return { + "Results": results, + "Solution": instance.solution, + "LP solution": instance.lp_solution, + "LP value": instance.lp_value, + "Upper bound": instance.upper_bound, + "Lower bound": instance.lower_bound, + "Violations": instance.found_violations, + } + + +class LearningSolver: + """ + Mixed-Integer Linear Programming (MIP) solver that extracts information + from previous runs, using Machine Learning methods, to accelerate the + solution of new (yet unseen) instances. + """ + + def __init__(self, + components=None, + gap_tolerance=None, + mode="exact", + solver="gurobi", + threads=4, + time_limit=None): + + self.components = {} + self.mode = mode + self.internal_solver = None + self.internal_solver_factory = solver + self.threads = threads + self.time_limit = time_limit + self.gap_tolerance = gap_tolerance + self.tee = False + + if components is not None: + for comp in components: + self.add(comp) + else: + self.add(ObjectiveValueComponent()) + self.add(PrimalSolutionComponent()) + self.add(LazyConstraintsComponent()) + + assert self.mode in ["exact", "heuristic"] + for component in self.components.values(): + component.mode = self.mode + + def _create_internal_solver(self): + logger.debug("Initializing %s" % self.internal_solver_factory) + if self.internal_solver_factory == "cplex": + solver = CPLEXSolver() + elif self.internal_solver_factory == "gurobi": + solver = GurobiSolver() + elif callable(self.internal_solver_factory): + solver = self.internal_solver_factory() + assert isinstance(solver, InternalSolver) + else: + raise Exception("solver %s not supported" % self.internal_solver_factory) + solver.set_threads(self.threads) + if self.time_limit is not None: + solver.set_time_limit(self.time_limit) + if self.gap_tolerance is not None: + solver.set_gap_tolerance(self.gap_tolerance) + return solver + + def solve(self, + instance, + model=None, + tee=False, + relaxation_only=False): + + if model is None: + model = instance.to_model() + + self.tee = tee + self.internal_solver = self._create_internal_solver() + self.internal_solver.set_instance(instance, model=model) + + logger.debug("Solving LP relaxation...") + results = self.internal_solver.solve_lp(tee=tee) + instance.lp_solution = self.internal_solver.get_solution() + instance.lp_value = results["Optimal value"] + + logger.debug("Running before_solve callbacks...") + for component in self.components.values(): + component.before_solve(self, instance, model) + + if relaxation_only: + return results + + results = self.internal_solver.solve(tee=tee) + + # Read MIP solution and bounds + instance.lower_bound = results["Lower bound"] + instance.upper_bound = results["Upper bound"] + instance.solution = self.internal_solver.get_solution() + + logger.debug("Calling after_solve callbacks...") + for component in self.components.values(): + component.after_solve(self, instance, model, results) + + return results + + def parallel_solve(self, + instances, + n_jobs=4, + label="Solve"): + + self.internal_solver = None + SOLVER[0] = self + INSTANCES[0] = instances + p_map_results = p_map(_parallel_solve, + list(range(len(instances))), + num_cpus=n_jobs, + desc=label) + + results = [p["Results"] for p in p_map_results] + for (idx, r) in enumerate(p_map_results): + instances[idx].solution = r["Solution"] + instances[idx].lp_solution = r["LP solution"] + instances[idx].lp_value = r["LP value"] + instances[idx].lower_bound = r["Lower bound"] + instances[idx].upper_bound = r["Upper bound"] + instances[idx].found_violations = r["Violations"] + + return results + + def fit(self, training_instances): + if len(training_instances) == 0: + return + for component in self.components.values(): + component.fit(training_instances) + + def add(self, component): + name = component.__class__.__name__ + self.components[name] = component + + def __getstate__(self): + self.internal_solver = None + return self.__dict__ diff --git a/src/python/miplearn/solvers/tests/__init__.py b/src/python/miplearn/solvers/tests/__init__.py new file mode 100644 index 0000000..2e19678 --- /dev/null +++ b/src/python/miplearn/solvers/tests/__init__.py @@ -0,0 +1,4 @@ +# 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. + diff --git a/src/python/miplearn/tests/test_solver.py b/src/python/miplearn/solvers/tests/test_solver.py similarity index 96% rename from src/python/miplearn/tests/test_solver.py rename to src/python/miplearn/solvers/tests/test_solver.py index abe303d..9242eb5 100644 --- a/src/python/miplearn/tests/test_solver.py +++ b/src/python/miplearn/solvers/tests/test_solver.py @@ -6,9 +6,11 @@ import pickle import tempfile import pyomo.environ as pe -from miplearn import LearningSolver, BranchPriorityComponent +from miplearn import BranchPriorityComponent +from miplearn import LearningSolver from miplearn.problems.knapsack import KnapsackInstance -from miplearn.solvers import GurobiSolver, CPLEXSolver +from miplearn.solvers.cplex import CPLEXSolver +from miplearn.solvers.gurobi import GurobiSolver def _get_instance(): diff --git a/src/python/miplearn/tests/test_benchmark.py b/src/python/miplearn/tests/test_benchmark.py index 5aa0441..aa99195 100644 --- a/src/python/miplearn/tests/test_benchmark.py +++ b/src/python/miplearn/tests/test_benchmark.py @@ -2,12 +2,11 @@ # Copyright (C) 2020, UChicago Argonne, LLC. All rights reserved. # Released under the modified BSD license. See COPYING.md for more details. +import os.path + from miplearn import LearningSolver, BenchmarkRunner from miplearn.problems.stab import MaxWeightStableSetGenerator from scipy.stats import randint -import numpy as np -import pyomo.environ as pe -import os.path def test_benchmark():