# An application of the Xpress callbacks to a Benders decomposition algorithm. # Uses preintsol callback for cut injection. # # Solves the problem: # min c1*x + c2*y # st A1*x + A2*y <=b (m constraints) # x binary n1-dimensional vector # y >=0 continuous n2-dimensional vector # # NOTE: This example is for illustration/tutorial purposes only. There is no # benefit in solving the generic problem defined below using Benders # decomposition. # Courtesy of Georgios Patsakis (UC Berkeley, Amazon) and Richard L.-Y. Chen (Amazon). # # (C) 2019-2026 Fair Isaac Corporation # Import necessary packages. import xpress as xp import sys # Initialize problem parameters. c1 = [1,6,5,7] # n1 x 1 c2 = [9,3,0,2,3] # n2 x 1 b = [-3,-4,1,4,5] # m x 1 A1 = [[0, -2, 3, 2], [-5, 0, -3, 1], [1, 0, 4, -2], [0, -3, 4, -1], [-5, -4, 3, 0]] A2 = [[3, 4, 2, 0, -5], [0, 2, 3, -2, 1], [2, 0, 1, -3, -5], [-5, 3, -2, -3, 0], [-2, 3, -1, 2, -4]] m = len(b) n1 = len(c1) n2 = len(c2) # Absolute accuracy for optimal objective. ObjAbsAccuracy=0 # SOLVE ORIGINAL PROBLEM WITHOUT DECOMPOSING print("**** Solving original problem without decomposing ****") p = xp.problem() # Define Variables. x = [p.addVariable(vartype=xp.binary) for _ in range(n1)] y = [p.addVariable() for _ in range(n2)] # positive real variable # Define Constraints. constr = [xp.Sum(A1[ii][jj]*x[jj] for jj in range(n1)) + \ xp.Sum(A2[ii][jj]*y[jj] for jj in range(n2)) \ <= b[ii] for ii in range(m)] p.addConstraint(constr) # Define Objective. p.setObjective(xp.Sum(c1[jj]*x[jj] for jj in range(n1)) + \ xp.Sum(c2[jj]*y[jj] for jj in range(n2)) , \ sense=xp.minimize) p.controls.outputlog = 0 # Suppress Xpress output # Solve and retrieve solution. p.optimize() if p.attributes.solvestatus != xp.SolveStatus.COMPLETED or \ p.attributes.solstatus != xp.SolStatus.OPTIMAL: raise RuntimeError('Problem could not be solved to MIP optimality') xopt = p.getSolution(x) yopt = p.getSolution(y) xopt_rounded = [0.0 if round(val, 2) == 0 else round(val, 2) for val in xopt] yopt_rounded = [0.0 if round(val, 2) == 0 else round(val, 2) for val in yopt] print(f"Solution without decomposing: obj: {p.attributes.objval:.2f}") print(f" x: {xopt_rounded}") print(f" y: {yopt_rounded}") print() # SOLVE DECOMPOSED PROBLEM # We define the following functions: # - subproblem: function that solves the subproblem for a given first stage # variable xhat. If the subproblem has an optimal solution, it returns the # the optimal dual multiplier associated with the non anticipativity # constraint, the optimal objective, and the label "Optimal". If the # subproblem is infeasible, it returns the dual ray of unboundedness and # the label "Infeasible". # # - preintsol_callback: A pre-integer solution callback is triggered every time # an integer solution is found, BEFORE it is accepted by the solver. This # callback validates the solution against the subproblem and can inject cuts # directly when soltype=0 (integer solution from node relaxation). # Subproblem Solver Function. def subproblem(xhat): # Input: # - xhat: n1x1 array is the first stage variable, # passed to the subproblem. # # Output: # return (lamb: n1x1 array, beta: scalar, flag:string) # - If the subproblem is Infeasible, the flag is 'Infeasible', and # lamb (n1x1) and beta (1x1) are the components of the dual ray of # unboundedness necessary to write a feasibility cut of the form: # sum(x[i]*lamb[i] for all i) +beta >= 0 # - If the subproblem is Optimal, the flag is 'Optimal', beta (1x1) is the # optimal objective, and lamb (n1x1) is the optimal dual multiplier # associated with the non anticipativity constraint, so that the # optimality cut will have the form: # theta >= sum(x[i]*lamb[i] for all i) + beta # IMPORTANT NOTE: The subproblem needs to ensure that upper bounds or non # zero lower bounds should ONLY BE DEFINED THROUGH CONSTRAINTS, not as part # of the variable definition. This is to ensure the feasibility cuts are # correct. # Side Note: If there are violated feasibility cuts, then the subproblem will be infeasible. # In such a case, it is a good practice to ensure that the subproblem is always # feasible through modeling, for instance by adding slacks to all the constraints. # This often improves performance because it tends to separate stronger feasibility cuts. # However, this is an advanced technique that is not implemented in this illustrative example. # Define and Solve Subproblem. # Initialize Problem. r=xp.problem() # Define Variables. y=[r.addVariable() for _ in range(n2)] # Positive real variable - second stage. z=[r.addVariable(lb=-xp.infinity) for _ in range(n1)] # Dummy copy variable, # must have the exact shape as xhat. epsilon=r.addVariable(lb=-xp.infinity) # Dummy variable to extract part of the # infeasibility ray (if infeasible). # Define Constraints. # Dummy constraint for optimality/feasibility cuts. # Note: make sure the index of the variable and of the position in the # constraint array is the same (or has a known mapping). dummy1= [z[i]==xhat[i] for i in range(n1)] # Dummy constraint for feasibility cut. dummy2= epsilon==1 # Second stage constraints, where RHS b has been multiplied by epsilon. constr=[xp.Sum(A1[ii][jj]*z[jj] for jj in range(n1)) + \ xp.Sum(A2[ii][jj]*y[jj] for jj in range(n2)) \ - epsilon*b[ii]<=0 for ii in range(m)] r.addConstraint(constr,dummy1,dummy2) # Define Objective. r.setObjective(xp.Sum(c2[jj]*y[jj] for jj in range(n2)), sense=xp.minimize) # Disable presolve. The only reason to do this is because in the case of # an infeasible problem, the presolve might identify infeasibility of the # problem without running simplex. Because of that, no dual infeasibility # certificate will be found, hence a dual ray of unboundedness will not be # returned, which means that a feasibility cut can not be formed. r.controls.presolve = 0 # Silence output. r.controls.outputlog = 0 # Solve optimization. r.optimize() # Find the indices of constraint dummy1, which will be used to access the # dual multipliers corresponding to the entries of xhat. xind1=[dummy1[ii].index for ii in range(n1)] if r.attributes.solvestatus != xp.SolveStatus.COMPLETED: print("ERROR: Subproblem was not solved. Terminating.") sys.exit() # Take cases depending on subproblem status. if r.attributes.solstatus == xp.SolStatus.OPTIMAL: # Optimal subproblem. # Retrieve optimal dual multiplier and objective and return. dualmult=r.getDuals() lamb=[dualmult[ii] for ii in xind1] beta=r.attributes.objval return(lamb,beta,'Optimal') elif r.attributes.solstatus == xp.SolStatus.INFEAS: # Infeasible subproblem. dray = r.getDualRay() if dray is None: print ("Could not retrieve a dual ray, return no good cut instead:") # This is just a cheat if the subproblem is found infeasible and # the optimizer fails to find a dual ray. Since all the first stage # variables are binary, we add a No-Good-Cut to exclude the point # xhat instead of an infeasibility cut. lamb=[2*xhat[ii]-1 for ii in range(n1)] beta=-sum(xhat)+1 else: # Extract the dual ray. print ("Dual Ray:", dray) # Extract the part of the dual ray corresponding to the first stage # variables xhat. lamb=[dray[ii] for ii in xind1] # Extract the constant from the dual ray entry of constraint dummy 2. beta=dray[dummy2.index] return(lamb,beta,'Infeasible') else: print("ERROR: Subproblem not optimal or infeasible. Terminating.") sys.exit() # Pre-Integer Solution Callback Function def preintsol_callback(p, data, soltype, cutoff): # Input: # NOTE: the input is populated by the solver when an integer solution is # found, BEFORE it is accepted. # - p: an xp.problem() (a thread-local copy of the problem from which the callback was triggered) # - data: structure that is passed to the callback from the problem, # specified in the addPreIntsolCallback function. No data is needed here, # so the addPreIntsolCallback() call uses None. # - soltype: 0 if the integer solution was found by a node relaxation, # 1 if found by a heuristic, 2 if provided by user # - cutoff: the cutoff value that the Optimizer will use if the solution # is accepted. # # Output: # return (reject, newcutoff) # - reject: 1 to reject the solution, 0 to accept it # - newcutoff: new cutoff value (or None to keep the current one) # Retrieve xhat and thetahat from the candidate integer solution. xhat = p.getCallbackSolution([x[ii] for ii in range(n1)]) thetahat = p.getCallbackSolution(theta) xhat_rounded = [0.0 if round(val, 2) == 0 else round(val, 2) for val in xhat] print(f"Integer solution found! x={xhat_rounded}, theta={thetahat:.2f}") # Solve the subproblem to check feasibility. (dual_mult, opt, status) = subproblem(xhat) print(f" Solving subproblem... subproblem obj = {opt:.2f}") if status == 'Infeasible': # The subproblem is infeasible. Add a feasibility cut. # For heuristic solutions (soltype != 0), we cannot add cuts, # and we can only reject the solution if it is not feasible. # Depending on the specific application, the heuristics could be # very unlikely to find valid feasible solutions (they don't # respect the Benders cuts we're generating), and the Benders # subproblem could be expensive to solve. # In such cases, it could be more convenient to reject all # heuristic solutions without solving the subproblem, or to # simply disable heuristics completely. if soltype != 0: print(" -> Subproblem infeasible - Heuristic solution, rejecting") return (1, None) print(" -> Subproblem infeasible - Adding feasibility cut") xind = [x[ii].index for ii in range(n1)] coefficients = dual_mult rhs = -opt p.addCuts([1], ['L'], [rhs], [0, len(xhat)], xind, coefficients) # IMPORTANT: Return 0 (accept) to keep the cuts. Returning reject=1 would drop the cuts. # When soltype=0, addCuts() causes the node LP to be resolved with the new cuts. return (0, None) elif status == 'Optimal': # Check if theta is sufficient for the optimal subproblem value. if thetahat >= opt - ObjAbsAccuracy: # The solution is feasible to the full problem. print(f" -> Subproblem obj <= theta -> Valid solution!") return (0, None) else: # The solution is infeasible; theta is too small. Add an optimality cut. # For heuristic solutions (soltype != 0), we cannot add cuts. if soltype != 0: print(" -> Subproblem obj > theta - Heuristic solution, rejecting") return (1, None) print(f" -> Subproblem obj > theta -> Adding optimality cut") xind = [x[ii].index for ii in range(n1)] thetaind = [theta.index] coefficients = [1] + [-dual_mult[ii] for ii in range(len(dual_mult))] rhs = opt - sum(dual_mult[ii]*xhat[ii] for ii in range(len(dual_mult))) p.addCuts([1], ['G'], [rhs], [0, len(xhat)+1], thetaind+xind, coefficients) # IMPORTANT: Return 0 (accept) to keep the cuts. Returning reject=1 would drop the cuts. # When soltype=0, addCuts() causes the node LP to be resolved with the new cuts. return (0, None) else: print("ERROR: Unexpected subproblem status. Rejecting solution.") return (1, None) # Main Problem. print("**** Benders decomposition algorithm ****") # Define main problem. p=xp.problem() # Define first stage variables. x=[p.addVariable(vartype=xp.binary) for _ in range(n1)] theta=p.addVariable() # (positive) second stage cost - change the default lower # bound if second stage cost may not be positive. # Define objective. p.setObjective(xp.Sum(c1[jj]*x[jj] for jj in range(n1)) + theta, sense=xp.minimize) # Add Pre-Integer Solution callback. # This callback is called when an integer solution is found, BEFORE it is # accepted by the solver. When soltype=0 (node relaxation solution), we can # add cuts directly from this callback, which will cause the node LP to be # resolved. p.addPreIntsolCallback(preintsol_callback, None, 0) # Deactivate presolve and symmetry detection. This is to ensure that the solver will not eliminate # variables, so the indices we retrieve for the variables and the cuts we add # correspond to/contain existing optimization variables. Presolve can be # activated, but additional steps are required for that (presolving the cuts # the same way as the rows of the matrix). p.setControl({"presolve":0,"mippresolve":0,"symmetry":0}) p.controls.outputlog = 0 # Suppress Xpress output # Solve the main problem. p.optimize() print() if p.attributes.solvestatus == xp.SolveStatus.COMPLETED and \ p.attributes.solstatus == xp.SolStatus.OPTIMAL: # Print results. print("**** Final solution (Benders) ****") xsol = p.getSolution(x) # Solve subproblem one final time to get y values final_sub = xp.problem() final_sub.controls.outputlog = 0 y_final = [final_sub.addVariable() for _ in range(n2)] constr_final = [xp.Sum(A1[ii][jj]*xsol[jj] for jj in range(n1)) + \ xp.Sum(A2[ii][jj]*y_final[jj] for jj in range(n2)) <= b[ii] for ii in range(m)] final_sub.addConstraint(constr_final) final_sub.setObjective(xp.Sum(c2[jj]*y_final[jj] for jj in range(n2)), sense=xp.minimize) final_sub.optimize() ysol = final_sub.getSolution(y_final) xsol_rounded = [0.0 if round(val, 2) == 0 else round(val, 2) for val in xsol] ysol_rounded = [0.0 if round(val, 2) == 0 else round(val, 2) for val in ysol] print(f"Solution with Benders decomposition: obj: {p.attributes.objval:.2f}") print(f" x: {xsol_rounded}") print(f" y: {ysol_rounded}") else: print("Could not solve the problem")