Initializing help system before first use

Solve a simple MIP using Benders decomposition


Type: Programming
Rating: 4 (medium-difficult)
Description: Solve a simple MIP using Benders decomposition. Courtesy of Georgios Patsakis (UC Berkeley, Amazon) and Richard L.-Y. Chen (Amazon).
File(s): benders_decomp.py


benders_decomp.py
# 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.00001

# 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")

© 2001-2026 Fair Isaac Corporation. All rights reserved. This documentation is the property of Fair Isaac Corporation (“FICO”). Receipt or possession of this documentation does not convey rights to disclose, reproduce, make derivative works, use, or allow others to use it except solely for internal evaluation purposes to determine whether to purchase a license to the software described in this documentation, or as otherwise set forth in a written software license agreement between you and FICO (or a FICO affiliate). Use of this documentation and the software described in it must conform strictly to the foregoing permitted uses, and no other use is permitted.