Source code for mpisppy.opt.lshaped

# Copyright 2020 by B. Knueven, D. Mildebrath, C. Muir, J-P Watson, and D.L. Woodruff
# This software is distributed under the 3-clause BSD License.
import pyomo.environ as pyo
import mpisppy.utils.sputils as sputils
import numpy as np
import itertools
import time
import sys
import mpisppy.spbase as spbase

from mpisppy import MPI
from pyomo.core.plugins.transform.discrete_vars import RelaxIntegerVars
from mpisppy.utils.sputils import find_active_objective
from mpisppy.utils.lshaped_cuts import LShapedCutGenerator
from mpisppy.spopt import set_instance_retry
from pyomo.core import (
    Objective, SOSConstraint, Constraint, Var
)
from pyomo.core.expr.visitor import identify_variables
from pyomo.repn.standard_repn import generate_standard_repn
from pyomo.core.expr.numeric_expr import LinearExpression

[docs] class LShapedMethod(spbase.SPBase): """ Base class for the L-shaped method for two-stage stochastic programs. Warning: This class explicitly assumes minimization. Args: options (dict): Dictionary of options. Possible (optional) options include - root_scenarios (list) - List of scenario names to include as part of the root problem (default []) - store_subproblems (boolean) - If True, the BendersDecomp object will maintain a dictionary containing the subproblems created by the BendersCutGenerator. - relax_root (boolean) - If True, the LP relaxation of the root problem is solved (i.e. integer variables in the root problem are relaxed). - scenario_creator_kwargs (dict) - Keyword args to pass to the scenario_creator. - valid_eta_lb (dict) - Dictionary mapping scenario names to valid lower bounds for the eta variables--i.e., a valid lower (outer) bound on the optimal objective value for each scenario. If none are provided, the lower bound is set to -sys.maxsize * scenario_prob, which may cause numerical errors. - indx_to_stage (dict) - Dictionary mapping the index of every variable in the model to the stage they belong to. all_scenario_names (list): List of all scenarios names present in the model (strings). scenario_creator (callable): Function which take a scenario name (string) and returns a Pyomo Concrete model with some things attached. scenario_denouement (callable, optional): Function which does post-processing and reporting. all_nodenames (list, optional): List of all node name (strings). Can be `None` for two-stage problems. mpicomm (MPI comm, optional): MPI communicator to use between all scenarios. Default is `MPI.COMM_WORLD`. scenario_creator_kwargs (dict, optional): Keyword arguments to pass to `scenario_creator`. """ def __init__( self, options, all_scenario_names, scenario_creator, scenario_denouement=None, all_nodenames=None, mpicomm=None, scenario_creator_kwargs=None, ): super().__init__( options, all_scenario_names, scenario_creator, scenario_denouement=scenario_denouement, all_nodenames=all_nodenames, mpicomm=mpicomm, scenario_creator_kwargs=scenario_creator_kwargs, ) if self.multistage: raise Exception("LShaped does not currently support multiple stages") self.options = options self.options_check() self.all_scenario_names = all_scenario_names self.root = None self.root_vars = None self.scenario_count = len(all_scenario_names) self.store_subproblems = False if "store_subproblems" in options: self.store_subproblems = options["store_subproblems"] self.root_scenarios = None if "root_scenarios" in options: self.root_scenarios = options["root_scenarios"] self.relax_root = False if "relax_root" in options: self.relax_root = options["relax_root"] self.valid_eta_lb = None if "valid_eta_lb" in options: self.valid_eta_lb = options["valid_eta_lb"] self.compute_eta_bound = False else: # fit the user does not provide a bound, compute one self.valid_eta_lb = { scen : (-sys.maxsize - 1) * 1. / len(self.all_scenario_names) \ for scen in self.all_scenario_names } self.compute_eta_bound = True if scenario_creator_kwargs is None: self.scenario_creator_kwargs = dict() else: self.scenario_creator_kwargs = scenario_creator_kwargs self.indx_to_stage = None self.has_valid_eta_lb = self.valid_eta_lb is not None self.has_root_scens = self.root_scenarios is not None if self.store_subproblems: self.subproblems = dict.fromkeys(scenario_names)
[docs] def options_check(self): """ Check to ensure that the user-specified options are valid. Requried options are: - root_solver (string) - Solver to use for the root problem. - sp_solver (string) - Solver to use for the subproblems. """ required = ["root_solver", "sp_solver"] if "root_solver_options" not in self.options: self.options["root_solver_options"] = dict() if "sp_solver_options" not in self.options: self.options["sp_solver_options"] = dict() self._options_check(required, self.options)
def _add_root_etas(self, root, index): def _eta_bounds(m, s): return (self.valid_eta_lb[s],None) root.eta = pyo.Var(index, within=pyo.Reals, bounds=_eta_bounds) def _create_root_no_scenarios(self): # using the first scenario as a basis root = self.scenario_creator( self.all_scenario_names[0], **self.scenario_creator_kwargs ) if self.relax_root: RelaxIntegerVars().apply_to(root) nonant_list, nonant_ids = _get_nonant_ids(root) self.root_vars = nonant_list for constr_data in list(itertools.chain( root.component_data_objects(SOSConstraint, active=True, descend_into=True) , root.component_data_objects(Constraint, active=True, descend_into=True))): if not _first_stage_only(constr_data, nonant_ids): _del_con(constr_data) # delete the second stage variables for var in list(root.component_data_objects(Var, active=True, descend_into=True)): if id(var) not in nonant_ids: _del_var(var) self._add_root_etas(root, self.all_scenario_names) # pulls the current objective expression, adds in the eta variables, # and removes the second stage variables from the expression obj = find_active_objective(root) repn = generate_standard_repn(obj.expr, quadratic=True) if len(repn.nonlinear_vars) > 0: raise ValueError("LShaped does not support models with nonlinear objective functions") linear_vars = list() linear_coefs = list() quadratic_vars = list() quadratic_coefs = list() ## we'll assume the constant is part of stage 1 (wlog it is), just ## like the first-stage bits of the objective constant = repn.constant ## only keep the first stage variables in the objective for coef, var in zip(repn.linear_coefs, repn.linear_vars): id_var = id(var) if id_var in nonant_ids: linear_vars.append(var) linear_coefs.append(coef) for coef, (x,y) in zip(repn.quadratic_coefs, repn.quadratic_vars): id_x = id(x) id_y = id(y) if id_x in nonant_ids and id_y in nonant_ids: quadratic_coefs.append(coef) quadratic_vars.append((x,y)) # checks if model sense is max, if so negates the objective if not self.is_minimizing: for i,coef in enumerate(linear_coefs): linear_coefs[i] = -coef for i,coef in enumerate(quadratic_coefs): quadratic_coefs[i] = -coef # add the etas for var in root.eta.values(): linear_vars.append(var) linear_coefs.append(1) expr = LinearExpression(constant=constant, linear_coefs=linear_coefs, linear_vars=linear_vars) if quadratic_coefs: expr += pyo.quicksum( (coef*x*y for coef,(x,y) in zip(quadratic_coefs, quadratic_vars)) ) root.del_component(obj) # set root objective function root.obj = pyo.Objective(expr=expr, sense=pyo.minimize) self.root = root def _create_root_with_scenarios(self): ef_scenarios = self.root_scenarios ## we want the correct probabilities to be set when ## calling create_EF if len(ef_scenarios) > 1: def scenario_creator_wrapper(name, **creator_options): scenario = self.scenario_creator(name, **creator_options) if not hasattr(scenario, '_mpisppy_probability'): scenario._mpisppy_probability = 1./len(self.all_scenario_names) return scenario root = sputils.create_EF( ef_scenarios, scenario_creator_wrapper, scenario_creator_kwargs=self.scenario_creator_kwargs, ) nonant_list, nonant_ids = _get_nonant_ids_EF(root) else: root = self.scenario_creator( ef_scenarios[0], **self.scenario_creator_kwargs, ) if not hasattr(root, '_mpisppy_probability')\ or root._mpisppy_probability == "uniform": root._mpisppy_probability = 1./len(self.all_scenario_names) nonant_list, nonant_ids = _get_nonant_ids(root) self.root_vars = nonant_list # creates the eta variables for scenarios that are NOT selected to be # included in the root problem eta_indx = [scenario_name for scenario_name in self.all_scenario_names if scenario_name not in self.root_scenarios] self._add_root_etas(root, eta_indx) obj = find_active_objective(root) repn = generate_standard_repn(obj.expr, quadratic=True) if len(repn.nonlinear_vars) > 0: raise ValueError("LShaped does not support models with nonlinear objective functions") linear_vars = list(repn.linear_vars) linear_coefs = list(repn.linear_coefs) quadratic_coefs = list(repn.quadratic_coefs) # adjust coefficients by scenario/bundle probability scen_prob = root._mpisppy_probability for i,var in enumerate(repn.linear_vars): if id(var) not in nonant_ids: linear_coefs[i] *= scen_prob for i,(x,y) in enumerate(repn.quadratic_vars): # only multiply through once if id(x) not in nonant_ids: quadratic_coefs[i] *= scen_prob elif id(y) not in nonant_ids: quadratic_coefs[i] *= scen_prob # NOTE: the LShaped code negates the objective, so # we do the same here for consistency if not self.is_minimizing: for i,coef in enumerate(linear_coefs): linear_coefs[i] = -coef for i,coef in enumerate(quadratic_coefs): quadratic_coefs[i] = -coef # add the etas for var in root.eta.values(): linear_vars.append(var) linear_coefs.append(1) expr = LinearExpression(constant=repn.constant, linear_coefs=linear_coefs, linear_vars=linear_vars) if repn.quadratic_vars: expr += pyo.quicksum( (coef*x*y for coef,(x,y) in zip(quadratic_coefs, repn.quadratic_vars)) ) root.del_component(obj) # set root objective function root.obj = pyo.Objective(expr=expr, sense=pyo.minimize) self.root = root def _create_shadow_root(self): root = pyo.ConcreteModel() arb_scen = self.local_scenarios[self.local_scenario_names[0]] nonants = arb_scen._mpisppy_node_list[0].nonant_vardata_list root_vars = list() for v in nonants: nonant_shadow = pyo.Var(name=v.name) root.add_component(v.name, nonant_shadow) root_vars.append(nonant_shadow) if self.has_root_scens: eta_indx = [scenario_name for scenario_name in self.all_scenario_names if scenario_name not in self.root_scenarios] else: eta_indx = self.all_scenario_names self._add_root_etas(root, eta_indx) root.obj = None self.root = root self.root_vars = root_vars
[docs] def set_eta_bounds(self): if self.compute_eta_bound: ## for scenarios not in self.local_scenarios, these will be a large negative number this_etas_lb = np.fromiter((self.valid_eta_lb[scen] for scen in self.all_scenario_names), float, count=len(self.all_scenario_names)) all_etas_lb = np.empty_like(this_etas_lb) self.mpicomm.Allreduce(this_etas_lb, all_etas_lb, op=MPI.MAX) for idx, s in enumerate(self.all_scenario_names): self.valid_eta_lb[s] = all_etas_lb[idx] # root may not have etas for every scenarios for s, v in self.root.eta.items(): v.setlb(self.valid_eta_lb[s])
[docs] def create_root(self): """ creates a ConcreteModel from one of the problem scenarios then modifies the model to serve as the root problem """ if self.cylinder_rank == 0: if self.has_root_scens: self._create_root_with_scenarios() else: self._create_root_no_scenarios() else: ## if we're not rank0, just create a root to ## hold the nonants and etas; rank0 will do ## the optimizing self._create_shadow_root()
[docs] def attach_nonant_var_map(self, scenario_name): instance = self.local_scenarios[scenario_name] subproblem_to_root_vars_map = pyo.ComponentMap() for var, rvar in zip(instance._mpisppy_data.nonant_indices.values(), self.root_vars): if var.name not in rvar.name: raise Exception("Error: Complicating variable mismatch, sub-problem variables changed order") subproblem_to_root_vars_map[var] = rvar # this is for interefacing with PH code instance._mpisppy_model.subproblem_to_root_vars_map = subproblem_to_root_vars_map
[docs] def create_subproblem(self, scenario_name): """ the subproblem creation function passed into the BendersCutsGenerator """ instance = self.local_scenarios[scenario_name] nonant_list, nonant_ids = _get_nonant_ids(instance) # NOTE: since we use generate_standard_repn below, we need # to unfix any nonants so they'll properly appear # in the objective fixed_nonants = [ var for var in nonant_list if var.fixed ] for var in fixed_nonants: var.fixed = False # pulls the scenario objective expression, removes the first stage variables, and sets the new objective obj = find_active_objective(instance) if not hasattr(instance, "_mpisppy_probability"): instance._mpisppy_probability = 1. / self.scenario_count _mpisppy_probability = instance._mpisppy_probability repn = generate_standard_repn(obj.expr, quadratic=True) if len(repn.nonlinear_vars) > 0: raise ValueError("LShaped does not support models with nonlinear objective functions") linear_vars = list() linear_coefs = list() quadratic_vars = list() quadratic_coefs = list() ## we'll assume the constant is part of stage 1 (wlog it is), just ## like the first-stage bits of the objective constant = repn.constant ## only keep the second stage variables in the objective for coef, var in zip(repn.linear_coefs, repn.linear_vars): id_var = id(var) if id_var not in nonant_ids: linear_vars.append(var) linear_coefs.append(_mpisppy_probability*coef) for coef, (x,y) in zip(repn.quadratic_coefs, repn.quadratic_vars): id_x = id(x) id_y = id(y) if id_x not in nonant_ids or id_y not in nonant_ids: quadratic_coefs.append(_mpisppy_probability*coef) quadratic_vars.append((x,y)) # checks if model sense is max, if so negates the objective if not self.is_minimizing: for i,coef in enumerate(linear_coefs): linear_coefs[i] = -coef for i,coef in enumerate(quadratic_coefs): quadratic_coefs[i] = -coef expr = LinearExpression(constant=constant, linear_coefs=linear_coefs, linear_vars=linear_vars) if quadratic_coefs: expr += pyo.quicksum( (coef*x*y for coef,(x,y) in zip(quadratic_coefs, quadratic_vars)) ) instance.del_component(obj) # set subproblem objective function instance.obj = pyo.Objective(expr=expr, sense=pyo.minimize) ## need to do this here for validity if computing the eta bound if self.relax_root: # relaxes any integrality constraints for the subproblem RelaxIntegerVars().apply_to(instance) if self.compute_eta_bound: for var in fixed_nonants: var.fixed = True opt = pyo.SolverFactory(self.options["sp_solver"]) if self.options["sp_solver_options"]: for k,v in self.options["sp_solver_options"].items(): opt.options[k] = v if sputils.is_persistent(opt): set_instance_retry(instance, opt, scenario_name) res = opt.solve(tee=False) else: res = opt.solve(instance, tee=False) eta_lb = res.Problem[0].Lower_bound self.valid_eta_lb[scenario_name] = eta_lb # if not done above if not self.relax_root: # relaxes any integrality constraints for the subproblem RelaxIntegerVars().apply_to(instance) # iterates through constraints and removes first stage constraints from the model # the id dict is used to improve the speed of identifying the stage each variables belongs to for constr_data in list(itertools.chain( instance.component_data_objects(SOSConstraint, active=True, descend_into=True) , instance.component_data_objects(Constraint, active=True, descend_into=True))): if _first_stage_only(constr_data, nonant_ids): _del_con(constr_data) # creates the sub map to remove first stage variables from objective expression complicating_vars_map = pyo.ComponentMap() subproblem_to_root_vars_map = pyo.ComponentMap() # creates the complicating var map that connects the first stage variables in the sub problem to those in # the root problem -- also set the bounds on the subproblem root vars to be none for better cuts for var, rvar in zip(nonant_list, self.root_vars): if var.name not in rvar.name: # rvar.name may be part of a bundle raise Exception("Error: Complicating variable mismatch, sub-problem variables changed order") complicating_vars_map[rvar] = var subproblem_to_root_vars_map[var] = rvar # these are already enforced in the root # don't need to be enfored in the subproblems var.setlb(None) var.setub(None) var.fixed = False # this is for interefacing with PH code instance._mpisppy_model.subproblem_to_root_vars_map = subproblem_to_root_vars_map if self.store_subproblems: self.subproblems[scenario_name] = instance return instance, complicating_vars_map
[docs] def lshaped_algorithm(self, converger=None): """ function that runs the lshaped.py algorithm """ if converger: converger = converger(self, self.cylinder_rank, self.n_proc) max_iter = 30 if "max_iter" in self.options: max_iter = self.options["max_iter"] tol = 1e-8 if "tol" in self.options: tol = self.options["tol"] verbose = True if "verbose" in self.options: verbose = self.options["verbose"] root_solver = self.options["root_solver"] sp_solver = self.options["sp_solver"] # creates the root problem self.create_root() m = self.root assert hasattr(m, "obj") # prevents problems from first stage variables becoming unconstrained # after processing _init_vars(self.root_vars) # sets up the BendersCutGenerator object m.bender = LShapedCutGenerator() m.bender.set_input(root_vars=self.root_vars, tol=tol, comm=self.mpicomm) # let the cut generator know who's using it, probably should check that this is called after set input m.bender.set_ls(self) # set the eta variables, removing this from the add_suproblem function so we can # Pass all the scenarios in the problem to bender.add_subproblem # and let it internally handle which ranks get which scenarios if self.has_root_scens: sub_scenarios = [ scenario_name for scenario_name in self.local_scenario_names if scenario_name not in self.root_scenarios ] else: sub_scenarios = self.local_scenario_names for scenario_name in self.local_scenario_names: if scenario_name in sub_scenarios: subproblem_fn_kwargs = dict() subproblem_fn_kwargs['scenario_name'] = scenario_name m.bender.add_subproblem( subproblem_fn=self.create_subproblem, subproblem_fn_kwargs=subproblem_fn_kwargs, root_eta=m.eta[scenario_name], subproblem_solver=sp_solver, subproblem_solver_options=self.options["sp_solver_options"] ) else: self.attach_nonant_var_map(scenario_name) # set the eta bounds if computed # by self.create_subproblem self.set_eta_bounds() if self.cylinder_rank == 0: opt = pyo.SolverFactory(root_solver) if opt is None: raise Exception("Error: Failed to Create Master Solver") # set options for k,v in self.options["root_solver_options"].items(): opt.options[k] = v is_persistent = sputils.is_persistent(opt) if is_persistent: set_instance_retry(m, opt, "root") t = time.time() res, t1, t2 = None, None, None # benders solve loop, repeats the benders root - subproblem # loop until either a no more cuts can are generated # or the maximum iterations limit is reached for self.iter in range(max_iter): if verbose and self.cylinder_rank == 0: if self.iter > 0: print("Current Iteration:", self.iter + 1, "Time Elapsed:", "%7.2f" % (time.time() - t), "Time Spent on Last Master:", "%7.2f" % t1, "Time Spent Generating Last Cut Set:", "%7.2f" % t2, "Current Objective:", "%7.2f" % m.obj.expr()) else: print("Current Iteration:", self.iter + 1, "Time Elapsed:", "%7.2f" % (time.time() - t), "Current Objective: -Inf") t1 = time.time() x_vals = np.zeros(len(self.root_vars)) eta_vals = np.zeros(self.scenario_count) outer_bound = np.zeros(1) if self.cylinder_rank == 0: if is_persistent: res = opt.solve(tee=False) else: res = opt.solve(m, tee=False) # LShaped is always minimizing outer_bound[0] = res.Problem[0].Lower_bound for i, var in enumerate(self.root_vars): x_vals[i] = var.value for i, eta in enumerate(m.eta.values()): eta_vals[i] = eta.value self.mpicomm.Bcast(x_vals, root=0) self.mpicomm.Bcast(eta_vals, root=0) self.mpicomm.Bcast(outer_bound, root=0) if self.is_minimizing: self._LShaped_bound = outer_bound[0] else: # LShaped is always minimizing, so negate # the outer bound for sharing broadly self._LShaped_bound = -outer_bound[0] if self.cylinder_rank != 0: for i, var in enumerate(self.root_vars): var._value = x_vals[i] for i, eta in enumerate(m.eta.values()): eta._value = eta_vals[i] t1 = time.time() - t1 # The hub object takes precedence over the converger # We'll send the nonants now, and check for a for # convergence if self.spcomm: self.spcomm.sync(send_nonants=True) if self.spcomm.is_converged(): break t2 = time.time() cuts_added = m.bender.generate_cut() t2 = time.time() - t2 if self.cylinder_rank == 0: for c in cuts_added: if is_persistent: opt.add_constraint(c) if verbose and len(cuts_added) == 0: print( f"Converged in {self.iter+1} iterations.\n" f"Total Time Elapsed: {time.time()-t:7.2f} " f"Time Spent on Last Master: {t1:7.2f} " f"Time spent verifying second stage: {t2:7.2f} " f"Final Objective: {m.obj.expr():7.2f}" ) self.first_stage_solution_available = True self.tree_solution_available = True break if verbose and self.iter == max_iter - 1: print("WARNING MAX ITERATION LIMIT REACHED !!! ") else: if len(cuts_added) == 0: break # The hub object takes precedence over the converger if self.spcomm: self.spcomm.sync(send_nonants=False) if self.spcomm.is_converged(): break if converger: converger.convergence_value() if converger.is_converged(): if verbose and self.cylinder_rank == 0: print( f"Converged to user criteria in {self.iter+1} iterations.\n" f"Total Time Elapsed: {time.time()-t:7.2f} " f"Time Spent on Last Master: {t1:7.2f} " f"Time spent verifying second stage: {t2:7.2f} " f"Final Objective: {m.obj.expr():7.2f}" ) break return res
def _del_con(c): parent = c.parent_component() if parent.is_indexed(): parent.__delitem__(c.index()) else: assert parent is c c.parent_block().del_component(c) def _del_var(v): parent = v.parent_component() if parent.is_indexed(): parent.__delitem__(v.index()) else: assert parent is v block = v.parent_block() block.del_component(v) def _get_nonant_ids(instance): assert len(instance._mpisppy_node_list) == 1 # set comprehension nonant_list = instance._mpisppy_node_list[0].nonant_vardata_list return nonant_list, { id(var) for var in nonant_list } def _get_nonant_ids_EF(instance): assert len(instance._mpisppy_data.nlens) == 1 ndn, nlen = list(instance._mpisppy_data.nlens.items())[0] ## this is for the cut variables, so we just need (and want) ## exactly one set of them nonant_list = list(instance.ref_vars[ndn,i] for i in range(nlen)) ## this is for adjusting the objective, so needs all the nonants ## in the EF snames = instance._ef_scenario_names nonant_ids = set() for s in snames: nonant_ids.update( (id(v) for v in \ getattr(instance, s)._mpisppy_node_list[0].nonant_vardata_list) ) return nonant_list, nonant_ids def _first_stage_only(constr_data, nonant_ids): """ iterates through the constraint in a scenario and returns if it only has first stage variables """ for var in identify_variables(constr_data.body): if id(var) not in nonant_ids: return False return True def _init_vars(varlist): ''' for every pyomo var in varlist without a value, sets it to the lower bound (if it exists), or the upper bound (if it exists, and the lower bound does note) or 0 (if neither bound exists). ''' value = pyo.value for var in varlist: if var.value is not None: continue if var.lb is not None: var.set_value(value(var.lb)) elif var.ub is not None: var.set_value(value(var.ub)) else: var.set_value(0)
[docs] def main(): import mpisppy.tests.examples.farmer as ref import os # Turn off output from all ranks except rank 1 if MPI.COMM_WORLD.Get_rank() != 0: sys.stdout = open(os.devnull, 'w') scenario_names = ['scen' + str(i) for i in range(3)] bounds = {i:-432000 for i in scenario_names} options = { "root_solver": "gurobi_persistent", "sp_solver": "gurobi_persistent", "sp_solver_options" : {"threads" : 1}, "valid_eta_lb": bounds, "max_iter": 10, } ls = LShapedMethod(options, scenario_names, ref.scenario_creator) res = ls.lshaped_algorithm() if ls.cylinder_rank == 0: print(res)
if __name__ == '__main__': main()