A Primal-Dual Global Optimization approach on the Cutting Stock Problem

This doc reads a bit lengthy, because it uses complex data structures (maybe for visualization purposes).
Therein it uses economic interpretation, which is hard to understand for me.
Therefore, I explicitly wrote the Lagrangian dual of the primal MIP, which makes the column generation subprocedure more intelligible.

Comments are welcome.

import JuMP, Gurobi; GRB_ENV = Gurobi.Env();
import LinearAlgebra.I as Identity

w, d, W, I = let # 📘 Data for the Cutting Stock Problem
    w = [75.0, 53.8, 53.0, 51.0, 50.2, 32.2, 30.8, 29.8, 20.1, 16.2, 14.5, 11.0, 8.6, 8.2, 6.6, 5.1]; # length (in meters)
    d = [189, 33, 36, 41, 35, 37, 44, 49, 37, 36, 42, 33, 47, 35, 49, 42]; # demand (in quantities)
    W = 100; # width of a raw roll
    I = length(d); @assert length(w) == I
    w, d, W, I
end;

# 🟣 Part I
# The following is a primary large-scale deterministic MIP formulation of the Cutting Stock Problem.
# The chief advantage ✅ of this problem formulation is that it can bring a global optimal solution,
# as long as you have enough time for computing.
function deterministic_mip(J) # J is a prescribed (large) number,
    # if the resulting termination status is INFEASIBLE, enlarge J.
    det = JuMP.Model(() -> Gurobi.Optimizer(GRB_ENV))
    JuMP.@variable(det, b[1:J], Bin) # b[j] == 1 ⟺ the j-th roll is used
    JuMP.@variable(det, n[1:I, 1:J] ≥ 0, Int) # n[i, j] is the number of (length == w[i])-subrolls cut from the j-th roll
    JuMP.@constraint(det, sum(n; dims = 2) .≥ d) # meet the demand
    JuMP.@constraint(det, w'n .≤ b'W) # width limit of each raw roll
    JuMP.@objective(det, Min, sum(b)) # number of used raw rolls
    JuMP.optimize!(det); JuMP.assert_is_solved_and_feasible(det; allow_local = false)
    num = JuMP.objective_value(det)
    println("The global optimal value is $(num)")
end
function deterministic_mip(b0, n0) # This is merely a variant of the above formulation in which you specify initial solutions
    J = length(b0) # 🟠
    det = JuMP.Model(() -> Gurobi.Optimizer(GRB_ENV))
    JuMP.@variable(det, b[1:J], Bin)
    JuMP.@variable(det, n[1:I, 1:J] ≥ 0, Int)
    JuMP.@constraint(det, sum(n; dims = 2) .≥ d)
    JuMP.@constraint(det, w'n .≤ b'W)
    JuMP.@objective(det, Min, sum(b))
    JuMP.set_start_value.(b, b0) # 🟠
    JuMP.set_start_value.(n, n0) # 🟠
    JuMP.optimize!(det); JuMP.assert_is_solved_and_feasible(det; allow_local = false)
    num = JuMP.objective_value(det)
    println("The global optimal value is $(num)")
end
# Do a blind (the number 500 is a trial) and brute-force (entirely by the MIP solver) solve
deterministic_mip(500); # 🖥️: The global optimal value is 334.0

# 🟣 Part II
# We introduce a pattern perspective of the Cutting Stock Problem and write an iterative algorithm---Column Generation.
# Each pattern is encoded into a column of the matrix `P` below.
# We justify the action "recruiting a new column" through the lens of Dual Cutting Plane method.
P = Identity(I); # ⚠️ This initial columns should make the initial `outer_mip` feasible
COT = 1e-6; # cut-off tolerance---a hyper parameter
function outer_mip(P) # employed to find feasible/suboptimal solutions
    J = size(P, 2)
    outer = JuMP.Model(() -> Gurobi.Optimizer(GRB_ENV)); JuMP.set_silent(outer)
    JuMP.@variable(outer, n[1:J] ≥ 0, Int)
    JuMP.@constraint(outer, [i = 1:I], view(P, i, :)'n ≥ d[i]) # [π ≥ 0] 🟡
    JuMP.@objective(outer, Min, sum(n))
    JuMP.optimize!(outer); JuMP.assert_is_solved_and_feasible(outer; allow_local = false)
    return ub, n = JuMP.objective_value(outer), JuMP.value.(n)
end;
# establish in advance (part of) the pricing subproblem
subproblem = JuMP.Model(() -> Gurobi.Optimizer(GRB_ENV)); JuMP.set_silent(subproblem);
JuMP.@variable(subproblem, p[1:I] ≥ 0, Int); JuMP.@constraint(subproblem, w'p ≤ W); # a feasible pattern
# establish in advance (part of) Lagrangian dual problem of the primal `outer_mip` above
outerDual = JuMP.Model(() -> Gurobi.Optimizer(GRB_ENV)); JuMP.set_silent(outerDual);
JuMP.@variable(outerDual, π[1:I] ≥ 0); JuMP.@objective(outerDual, Max, d'π);
J = size(P, 2);
JuMP.@constraint(outerDual, [j = 1:J], 1 ≥ view(P, :, j)'π); # [⇐ n ≥ 0] 🟡
while true # the aim of this main loop is to recruit as many advantageous columns as possible,
    # in order to lower the primal objective value of the `outer_mip`.
    # This goal is in turn pursued by attempting to lower the Lagrangian dual bound of `outer_mip`.
    # Hence, a Dual Cutting Plane method (i.e. the `@constraint` below) is employed
    Π, lb = let
        JuMP.optimize!(outerDual); JuMP.assert_is_solved_and_feasible(outerDual; allow_local = false)
        JuMP.value.(π), JuMP.objective_value(outerDual)
    end
    p_new, vio_level = let
        JuMP.@objective(subproblem, Max, Π'p)
        JuMP.optimize!(subproblem); JuMP.assert_is_solved_and_feasible(subproblem; allow_local = false)
        JuMP.value.(p), JuMP.objective_value(subproblem) - 1
    end
    if vio_level > COT # ✅ Note that this `if` is a hallmark of the Cutting Plane method
        @info "At J = $J, (invalid) lb = $lb"
        JuMP.@constraint(outerDual, 1 ≥ p_new'π) # add a new cut to lower the lb of the outer_mip
        global P = [P p_new] # recruit a new advantageous column
        global J = size(P, 2) # update J
    else
        @info "At J = $J, (invalid) lb = $lb\nno more advantageous columns can be found, thus break"
        break
    end
end # during the execution of this loop, we observe that `lb` is indeed iteratively lowered.
# Note that `size(P, 2)` is enlarged compared to that initial Identity state.
# Next We generate a feasible solution (in the pattern-formulation) to the primal problem.
# This feasible solution cannot assure global optimality because we have no idea
# whether there is still an advantageous column unrecruited. And I'm unaware of
# an efficient algorithm to identify it without branching.
ub, n = outer_mip(P)

# 🟣 Part III
# The feasible solution found in Part II is suboptimal. But we can employ it as a starting
# solution to be delivered to the MIP solver, so that we end up with a global solution. 
# To this end, we need to convert it to (b0, n0).
j_vec = findall(n -> n > .5, n) # used columns indices
n, P = n[j_vec], P[:, j_vec] # used part
n = round.(Int, n)
P = round.(Int, P)
n0 = hcat([repeat(view(P, :, j), 1, n[j]) for j in eachindex(n)]...)
b0 = trues(size(n0, 2))
@assert all(sum(n0; dims = 2) .≥ d) # do a demand feasibility check
# you can compare this "prepared" solve with that blind one in Part I
deterministic_mip(b0, n0) # 🖥️: The global optimal value is 334.0
2 Likes

If you think part of the documentation of a package is too lengthy or can be improved, why not open an issue or PR?

1 Like

For 2 reasons, maybe there are some suggestions I can hear. And my study about this problem is not finished yet. I’ll try if I can discover more insights.
The cutting pattern formulation, although looks appealing, does not admit a deterministic formulation, thereby global optimality is not assured. In contrast, the first formulation, despite looks a bit “silly”, it is indeed a deterministic MIP that can be guaranteed to be solved to global optimality, provided enough time to compute.

The suboptimal solution derived from column generation can serve as an starting feasible solution that can be fed to the primary roll-wise large-scale deterministic formulation—added in my “Part III”. Therefore we obviate the need for branch-and-price, yet still end up with a global optimization. You can take a look when you have time @odow

Hey,

At each iteration of column generation, you look at the column that has best reduced cost.

In the original formulation of the tutorial, all rolls are identical. You therefore just need to solve one knapsack at each iteration.

Make sure the master can pick any number of columns (patterns)

Focusing on subproblem for roll j=1, given \pi_i the dual value of demand covering constraint of item I, to generate the column with most negative reduced cost, you optimize :

\min_{x,y} y_1 - \sum_i \pi_i x_{i1} s.t. knapsack constraint

(Penalize demand convering contraint violation by \pi on the original formulation and you’ll end up with same problem as above if you focus only on roll j=1).

Assume y_1 = 1 because a master solution containing 0 pattern is feasible.

It yields: \min_{x} 1 - \sum_i \pi_i x_{i1} s.t. knapsack constraint
If reduced cost is negative, you keep the pattern because it improves the LP restricted master.

and this is equivalent to

\max_{x} \sum_i \pi_i x_{i1} s.t. knapsack constraint.
If reduced cost is > 1, keep the pattern.

Hope it helps

1 Like

To be honest, I cannot fully get the name “reduced cost”, along with “shadow price”. I think it should be enough for people to fathom what a “dual variable” is. I can only imagine it being a quantity like “rate of change” (for a scalar constraint) and a “gradient” (for a vector constraint).
Another point in MIP is, Lagrangian relaxation is always tighter than natural LP relaxation.
The JuMP doc uses “do LP relaxation and get dual”. In contrast, I think my Lagrangian dual method is better and can be generalized to more complex problems.

why is a master solution containing 0 pattern is feasible?
In my code above, my initial pattern is simply an identity matrix (each type of demand is considered independently), which saves effort.

Whatever, if finally we reach the same formulation, then both points of view would be nice.


Several places that the JuMP’s doc can be improved, in my opinion, is

  i   w_i d_i
 ------------
  1  75.0  38
  2  75.0  44
  3  75.0  30
  4  75.0  41
  5  75.0  36
  6  53.8  33

The i in 1:5 can be merged to belong to the same i, otherwise it looks weird.

Second,

function solve_pricing(data::Data, π::Vector{Float64})
..............
    return nothing
end

The subproblem is enclosed in a function, meaning that each iteration we’ll rebuild this JuMP model. In my code, this do not happen. Therefore I saved this overhead.

Third, I completely replaced this subsection " Choosing the initial set of patterns" with an Identity matrix, thus made the exposition shorter and smoother.

Here is a code in which I adopted an alternative algorithm for the same problem.
Note that this post is independent of my initial post.
And it can also find the global optimal.
Besided, I generated a larger scale test instance.

A primary version
import JuMP, Gurobi; GRB_ENV = Gurobi.Env();
import LinearAlgebra.⋅ as ⋅
w, d, W, I = let # 📘 Data for the Cutting Stock Problem
    w = [59, 61, 62, 63, 65, 66, 67, 70, 72, 74, 77, 78, 79, 81, 83, 84, 86, 88, 89, 91, 92, 103, 104, 105, 106, 119, 120, 122, 125, 131, 133, 134, 136, 142, 144, 145, 148, 151, 154, 157, 159, 161, 166, 167, 168, 170, 174, 175, 176, 178, 180, 181]
    d = [77, 48, 62, 64, 66, 49, 25, 21, 56, 47, 53, 51, 23, 73, 71, 22, 23, 68, 71, 51, 66, 55, 58, 76, 71, 31, 53, 46, 57, 56, 63, 77, 53, 65, 28, 70, 72, 35, 46, 23, 34, 39, 36, 68, 31, 64, 40, 22, 36, 37, 39, 57]
    W = 223; # width of a raw roll
    I = length(d); @assert length(w) == I
    w, d, W, I
end;

J, LagIniBnd = 2000, 2100 # 📗 hyper parameter of the algorithm devised in this file

Mip = JuMP.Model(() -> Gurobi.Optimizer(GRB_ENV));
JuMP.@variable(Mip, 0 ≤ b[1:J] ≤ 1, Int);    # consider b[j] ∀ j
JuMP.@variable(Mip, 0 ≤ n[1:I, 1:J], Int);   # consider n[1:I, j] ∀ j
JuMP.@constraint(Mip, [j = 1:J], w'view(n, :, j) ≤ b[j]W); # ∀ j
JuMP.@objective(Mip, Min, sum(b[j] for j in 1:J)); # for each j
JuMP.@constraint(Mip, [i = 1:I], sum(n[i, j] for j in 1:J) ≥ d[i]); # [π ≥ 0] 🟡
# 💡: you can try a direct solve at this line, and see in which point it
# is inferior, compared with that final solve at the end of this code file

Min_j = JuMP.Model(() -> Gurobi.Optimizer(GRB_ENV)); JuMP.set_silent(Min_j); # the j-th block
JuMP.@variable(Min_j, 0 ≤ b_j ≤ 1, Int);
JuMP.@variable(Min_j, 0 ≤ n_j[1:I], Int);
JuMP.@constraint(Min_j, w'n_j ≤ (b_j)W);

Lag = JuMP.Model(() -> Gurobi.Optimizer(GRB_ENV)); JuMP.set_silent(Lag); # Lagrangian dual of `Mip`
JuMP.@variable(Lag, 0 ≤ π[1:I]);
JuMP.@variable(Lag, θ); # All J blocks are homogeneous, therefore only one subblock is needed
JuMP.@variable(Lag, Λ ≤ LagIniBnd); # ⚠️ add an large artificial bound
JuMP.@constraint(Lag, Λ ≤ d'π + (J)θ);
JuMP.@objective(Lag, Max, Λ);

function get_best_dual()
    for Lag_ite in 1:typemax(Int16)
        JuMP.optimize!(Lag); JuMP.assert_is_solved_and_feasible(Lag; allow_local = false)
        Π, ub = JuMP.value.(π), JuMP.objective_bound(Lag)
        JuMP.@objective(Min_j, Min, b_j - n_j'Π)
        JuMP.optimize!(Min_j); JuMP.assert_is_solved_and_feasible(Min_j; allow_local = false)
        bj, nj, Θ = JuMP.value(b_j), JuMP.value.(n_j), JuMP.objective_value(Min_j)
        lb = d'Π + (J)Θ
        @info "Lag_ite = $Lag_ite ▶ lb = $lb | $ub = ub"
        if lb + 1e-5 > ub
            BndDual = JuMP.dual(JuMP.UpperBoundRef(Λ))
            isapprox(BndDual, 0; atol=1e-7) || error("BndDual is $BndDual")
            return Π
        end
        JuMP.@constraint(Lag, θ ≤ bj - nj'π);
    end
end;

let # strengthen the original Mip
    Π, rhs = let
        Π = get_best_dual()
        JuMP.@objective(Min_j, Min, b_j - n_j'Π)
        JuMP.optimize!(Min_j); JuMP.assert_is_solved_and_feasible(Min_j; allow_local = false)
        rhs = JuMP.objective_value(Min_j)
        Π, rhs
    end
    JuMP.@constraint(Mip, [j = 1:J], b[j] - view(n, :, j)'Π ≥ rhs) # 🟣 add DWB cut
end

JuMP.optimize!(Mip); JuMP.solution_summary(Mip) # solve the DWB-equipped orginal Mip to global optimality

# 🗒️ Test Result: 
# 1. with Lagrangian bound:     Explored 1 nodes (282897 simplex iterations) in 108.98 seconds (104.16 work units), Find the OPTIMAL solution
# 2. without Lagrangian bound:  After Exploring 1245 nodes (1769432 simplex iterations) in 299.92 seconds (349.91 work units), ObjVal = 1.47000e+03, OBjBnd = 1.44800e+03

Edit on 2025/6/16

Norm-2 Regularization
J, LagIniBnd = 1531, 1530 # 📗 hyper parameter of the algorithm devised in this file

Min_j = JuMP.Model(() -> Gurobi.Optimizer(GRB_ENV)); JuMP.set_silent(Min_j); # the j-th block
JuMP.@variable(Min_j, b_j, Bin);
JuMP.@variable(Min_j, 0 ≤ n_j[1:I], Int);
JuMP.@constraint(Min_j, w'n_j ≤ (b_j)W);

Lag = JuMP.Model(() -> Gurobi.Optimizer(GRB_ENV)); JuMP.set_silent(Lag); # Lagrangian dual of `Mip`
JuMP.@variable(Lag, 0 ≤ π[i = 1:I]);
JuMP.@variable(Lag, θ); # All J blocks are homogeneous, therefore only one subblock is needed
JuMP.@variable(Lag, Λ ≤ LagIniBnd); # ⚠️ add an large artificial bound
JuMP.@expression(Lag, LagObjBias, d'π)
JuMP.@constraint(Lag, Λ ≤ LagObjBias + (J)θ);
JuMP.@variable(Lag, nm2_dist); # used in the regularization step

lb, ub = 0., LagIniBnd # valid finite initial bounds
Π = falses(I) # (trial) dual solution starts from zero
t0 = time()
for Lag_ite in 1:typemax(Int)
    # outer layer
    JuMP.@objective(Lag, Max, Λ); JuMP.optimize!(Lag); JuMP.assert_is_solved_and_feasible(Lag; allow_local = false)
    ub = JuMP.objective_bound(Lag)
    good_objc = JuMP.@constraint(Lag, .7 * ub + .3 * lb ≤ Λ)
    quadc = JuMP.@constraint(Lag, [nm2_dist; π - Π] ∈ JuMP.SecondOrderCone())
    JuMP.@objective(Lag, Min, nm2_dist); JuMP.optimize!(Lag); JuMP.assert_is_solved_and_feasible(Lag; allow_local = false)
    Π = JuMP.value.(π) # Currently we take this as our trial solution
    common_value = JuMP.value(LagObjBias)
    JuMP.delete(Lag, good_objc); JuMP.delete(Lag, quadc)
    ### subproblems
    c_b_j = 1.0
    c_n_j = [-Π[i] for i = 1:I]
    JuMP.@objective(Min_j, Min, +(c_b_j ⋅ b_j, c_n_j ⋅ n_j))
    JuMP.optimize!(Min_j); JuMP.assert_is_solved_and_feasible(Min_j; allow_local = false)
    # add cut to Lag
    ec_b_j = 1.0
    ec_n_j = [-π[i] for i = 1:I]
    JuMP.@constraint(Lag, θ ≤ +(ec_b_j ⋅ JuMP.value(b_j), ec_n_j ⋅ JuMP.value.(n_j)))
    ### lb | ub
    lb_new = common_value + (J)JuMP.objective_value(Min_j)
    lb = max(lb, lb_new)
    @info "Lag_ite = $Lag_ite ▶ lb = $lb | $ub = ub"
    if lb + 1e-5 > ub
        @info "LagDualOpt converges"
        break
    end
end
println("lb = $lb | $ub = ub") # lb = 1467.0833254250904 | 1467.0833333333337 = ub
println("QuadRegularization: $(time() - t0) seconds") # 0.795
Norm-1 Regularization

J, LagIniBnd = 1531, 1530 # 📗 hyper parameter of the algorithm devised in this file

Min_j = JuMP.Model(() -> Gurobi.Optimizer(GRB_ENV)); JuMP.set_silent(Min_j); # the j-th block
JuMP.@variable(Min_j, b_j, Bin);
JuMP.@variable(Min_j, 0 ≤ n_j[1:I], Int);
JuMP.@constraint(Min_j, w'n_j ≤ (b_j)W);

Lag = JuMP.Model(() -> Gurobi.Optimizer(GRB_ENV)); JuMP.set_silent(Lag); # Lagrangian dual of `Mip`
JuMP.@variable(Lag, 0 ≤ π[i = 1:I]);
JuMP.@variable(Lag, θ); # All J blocks are homogeneous, therefore only one subblock is needed
JuMP.@variable(Lag, Λ ≤ LagIniBnd); # ⚠️ add an large artificial bound
JuMP.@expression(Lag, LagObjBias, d'π)
JuMP.@constraint(Lag, Λ ≤ LagObjBias + (J)θ);
# used in the norm_1 regularization step
JuMP.@variable(Lag, π_m[i = 1:I])
JuMP.@variable(Lag, π_p[i = 1:I])
JuMP.@constraint(Lag, π_m + 2π == π_p)
JuMP.@expression(Lag, nm1_dist, sum(π_m + π_p))

lb, ub = 0., LagIniBnd # valid finite initial bounds
Π = falses(I) # (trial) dual solution starts from zero
t0 = time()
for Lag_ite in 1:typemax(Int)
    # outer layer
    JuMP.@objective(Lag, Max, Λ); JuMP.optimize!(Lag); JuMP.assert_is_solved_and_feasible(Lag; allow_local = false)
    ub = JuMP.objective_bound(Lag)
    good_objc = JuMP.@constraint(Lag, .7 * ub + .3 * lb ≤ Λ)
    JuMP.set_lower_bound.(π_m, -Π)
    JuMP.set_lower_bound.(π_p, Π)
    JuMP.@objective(Lag, Min, nm1_dist); JuMP.optimize!(Lag); JuMP.assert_is_solved_and_feasible(Lag; allow_local = false)
    Π = JuMP.value.(π) # Currently we take this as our trial solution
    common_value = JuMP.value(LagObjBias)
    JuMP.delete(Lag, good_objc)
    ### subproblems
    c_b_j = 1.0
    c_n_j = [-Π[i] for i = 1:I]
    JuMP.@objective(Min_j, Min, +(c_b_j ⋅ b_j, c_n_j ⋅ n_j))
    JuMP.optimize!(Min_j); JuMP.assert_is_solved_and_feasible(Min_j; allow_local = false)
    # add cut to Lag
    ec_b_j = 1.0
    ec_n_j = [-π[i] for i = 1:I]
    JuMP.@constraint(Lag, θ ≤ +(ec_b_j ⋅ JuMP.value(b_j), ec_n_j ⋅ JuMP.value.(n_j)))
    ### lb | ub
    lb_new = common_value + (J)JuMP.objective_value(Min_j)
    lb = max(lb, lb_new)
    @info "Lag_ite = $Lag_ite ▶ lb = $lb | $ub = ub"
    if lb + 1e-5 > ub
        @info "LagDualOpt converges"
        break
    end
end
println("lb = $lb | $ub = ub") # lb = 1467.0833287621804 | 1467.0833333333335 = ub
println("Norm_1 Regularization: $(time() - t0) seconds") # 0.73

I think the general theory should not appear in a JuMP tutorial. My opinion is that the goal of JuMP documentation is to show what you can do with this package. In the case of column generation, the tutorial should focus on how to dynamically add variables to a formulation.

Take a look at the Dantzig-Wolfe decomposition. You’ll see that it’s based on a Lagrangian relaxation of the original problem and you need column generation to optimize the reformulation (e.g. the work of Vanderbeck & Wolsey: https://www.math.u-bordeaux.fr/~fvanderb/papers/refDecompIPworkingPaper.pdf part 3),

If you reformulate the original problem of the tutorial with a Dantzig-Wolfe decomposition, get rid of the convex combination constraint in the master and solve only one subproblem, you’ll get exactly the same algorithm with the same master and subproblem as in the tutorial. Reduced costs, dual values and Lagrangian are linked.

Good luck, it’s a nice topic :slight_smile:

2 Likes

Yes, I’ve noticed this point

# Create a new column
push!(x, @variable(model, lower_bound = 0))
# Update the non-zeros in the coefficient matrix
set_normalized_coefficient(demand[i], x[end], count)

To be honest, I’ve been using JuMP for 2 years, yet never used these 2 methods. (neither this time in this topic). (But it also reflects that Cutting Plane is indeed simpler—purely from a coding perspective.)

Thanks for sharing! I’ll have a read.

Joining my code in #1 and #7 posts, I’m glad to declare that I discover a (presumably) the most efficient algorithm so far to solving the cutting stock problem. The main ideas include

  1. We need a deterministic MIP formulation to the Cutting Stock Problem being studied. Such that we can quest a global optimal solution with the help of Gurobi (or other MIP solver).
  2. From primal side, we employ the idea of Column Generation to find a good heuristic solution
  3. From dual side, we enforce the Lagrangian dual bound

My code is available here

Primal-Dual Algorithm for Cutting Stock
import JuMP, Gurobi; GRB_ENV = Gurobi.Env();
import LinearAlgebra.I as Identity

w, d, W, I = let # 📘 Data for the Cutting Stock Problem
    w = [59, 61, 62, 63, 65, 66, 67, 70, 72, 74, 77, 78, 79, 81, 83, 84, 86, 88, 89, 91, 92, 103, 104, 105, 106, 119, 120, 122, 125, 131, 133, 134, 136, 142, 144, 145, 148, 151, 154, 157, 159, 161, 166, 167, 168, 170, 174, 175, 176, 178, 180, 181]
    d = [77, 48, 62, 64, 66, 49, 25, 21, 56, 47, 53, 51, 23, 73, 71, 22, 23, 68, 71, 51, 66, 55, 58, 76, 71, 31, 53, 46, 57, 56, 63, 77, 53, 65, 28, 70, 72, 35, 46, 23, 34, 39, 36, 68, 31, 64, 40, 22, 36, 37, 39, 57]
    W = 223; # width of a raw roll
    I = length(d); @assert length(w) == I
    w, d, W, I
end;

# 🟣 §I: Primal Heuristic Solution via Column Generation
P = Identity(I); # ⚠️ This initial columns should make the initial `outer_mip` feasible
COT = 1e-6; # cut-off tolerance---a hyper parameter
function outer_mip(P) # employed to find feasible/suboptimal solutions
    J = size(P, 2)
    outer = JuMP.Model(() -> Gurobi.Optimizer(GRB_ENV)); JuMP.set_silent(outer)
    JuMP.@variable(outer, n[1:J] ≥ 0, Int)
    JuMP.@constraint(outer, [i = 1:I], view(P, i, :)'n ≥ d[i]) # [π ≥ 0] 🟡
    JuMP.@objective(outer, Min, sum(n))
    JuMP.optimize!(outer); JuMP.assert_is_solved_and_feasible(outer; allow_local = false)
    return ub, n = JuMP.objective_value(outer), JuMP.value.(n)
end;
subproblem = JuMP.Model(() -> Gurobi.Optimizer(GRB_ENV)); JuMP.set_silent(subproblem);
JuMP.@variable(subproblem, p[1:I] ≥ 0, Int); JuMP.@constraint(subproblem, w'p ≤ W); # a feasible pattern
outerDual = JuMP.Model(() -> Gurobi.Optimizer(GRB_ENV)); JuMP.set_silent(outerDual);
JuMP.@variable(outerDual, π[1:I] ≥ 0); JuMP.@objective(outerDual, Max, d'π);
J = size(P, 2);
JuMP.@constraint(outerDual, [j = 1:J], 1 ≥ view(P, :, j)'π); # [⇐ n ≥ 0] 🟡
while true
    Π, lb = let
        JuMP.optimize!(outerDual); JuMP.assert_is_solved_and_feasible(outerDual; allow_local = false)
        JuMP.value.(π), JuMP.objective_value(outerDual)
    end
    p_new, vio_level = let
        JuMP.@objective(subproblem, Max, Π'p)
        JuMP.optimize!(subproblem); JuMP.assert_is_solved_and_feasible(subproblem; allow_local = false)
        JuMP.value.(p), JuMP.objective_value(subproblem) - 1
    end
    if vio_level > COT # ✅ Note that this `if` is a hallmark of the Cutting Plane method
        @info "At J = $J, (invalid) lb = $lb"
        JuMP.@constraint(outerDual, 1 ≥ p_new'π) # add a new cut to lower the lb of the outer_mip
        global P = [P p_new] # recruit a new advantageous column
        global J = size(P, 2) # update J
    else
        @info "At J = $J, (invalid) lb = $lb\nno more advantageous columns can be found, thus break"
        break
    end
end
ub, n = outer_mip(P)
j_vec = findall(n -> n > .5, n) # used columns indices
n, P = round.(Int, n[j_vec]), round.(Int, P[:, j_vec])
n0 = hcat([repeat(view(P, :, j), 1, n[j]) for j in eachindex(n)]...) # 🟠
b0 = trues(size(n0, 2)) # 🟠
@assert all(sum(n0; dims = 2) .≥ d) # do a demand feasibility check
J = length(b0)
LagIniBnd = 1.1J

# 🟣 §II: Lagrangian Dual Bound 
Mip = JuMP.Model(() -> Gurobi.Optimizer(GRB_ENV));
JuMP.@variable(Mip, 0 ≤ b[1:J] ≤ 1, Int);
JuMP.@variable(Mip, 0 ≤ n[1:I, 1:J], Int);
JuMP.set_start_value.(b, b0) # 🟠
JuMP.set_start_value.(n, n0) # 🟠
JuMP.@constraint(Mip, [j = 1:J], w'view(n, :, j) ≤ b[j]W);
JuMP.@objective(Mip, Min, sum(b[j] for j in 1:J));
JuMP.@constraint(Mip, [i = 1:I], sum(n[i, j] for j in 1:J) ≥ d[i]); # [π ≥ 0] 🟡
Min_j = JuMP.Model(() -> Gurobi.Optimizer(GRB_ENV)); JuMP.set_silent(Min_j); # the j-th block
JuMP.@variable(Min_j, 0 ≤ b_j ≤ 1, Int);
JuMP.@variable(Min_j, 0 ≤ n_j[1:I], Int);
JuMP.@constraint(Min_j, w'n_j ≤ (b_j)W);
Lag = JuMP.Model(() -> Gurobi.Optimizer(GRB_ENV)); JuMP.set_silent(Lag); # Lagrangian dual of `Mip`
JuMP.@variable(Lag, 0 ≤ π[1:I]);
JuMP.@variable(Lag, θ); # All J blocks are homogeneous, therefore only one subblock is needed
JuMP.@variable(Lag, Λ ≤ LagIniBnd); # ⚠️ add an large artificial bound
JuMP.@constraint(Lag, Λ ≤ d'π + (J)θ);
JuMP.@objective(Lag, Max, Λ);
function get_best_dual()
    for Lag_ite in 1:typemax(Int16)
        JuMP.optimize!(Lag); JuMP.assert_is_solved_and_feasible(Lag; allow_local = false)
        Π, ub = JuMP.value.(π), JuMP.objective_bound(Lag)
        JuMP.@objective(Min_j, Min, b_j - n_j'Π)
        JuMP.optimize!(Min_j); JuMP.assert_is_solved_and_feasible(Min_j; allow_local = false)
        bj, nj, Θ = JuMP.value(b_j), JuMP.value.(n_j), JuMP.objective_value(Min_j)
        lb = d'Π + (J)Θ
        @info "Lag_ite = $Lag_ite ▶ lb = $lb | $ub = ub"
        if lb + 1e-5 > ub
            BndDual = JuMP.dual(JuMP.UpperBoundRef(Λ))
            isapprox(BndDual, 0; atol=1e-7) || error("BndDual is $BndDual")
            return Π
        end
        JuMP.@constraint(Lag, θ ≤ bj - nj'π);
    end
end;
let # strengthen the original Mip
    Π, rhs = let
        Π = get_best_dual()
        JuMP.@objective(Min_j, Min, b_j - n_j'Π)
        JuMP.optimize!(Min_j); JuMP.assert_is_solved_and_feasible(Min_j; allow_local = false)
        rhs = JuMP.objective_value(Min_j)
        Π, rhs
    end
    JuMP.@constraint(Mip, [j = 1:J], b[j] - view(n, :, j)'Π ≥ rhs) # 🟣 add DWB cut
end

# 🟣 §III: Solve to Global Optimality with (Primal Solution @§I, Dual Bound @§II)
# This should be observably a lot easier than a direct crude solve
JuMP.optimize!(Mip); JuMP.solution_summary(Mip)

Let’s take a look at how eased Gurobi is

Optimize a model with 2988 rows, 77804 columns and 231944 nonzeros
Model fingerprint: 0xd4ea8bd8
Variable types: 0 continuous, 77804 integer (0 binary)
Coefficient statistics:
  Matrix range     [3e-01, 2e+02]
  Objective range  [1e+00, 1e+00]
  Bounds range     [1e+00, 1e+00]
  RHS range        [2e+01, 8e+01]

Loaded user MIP start with objective 1468

Presolve time: 0.33s
Presolved: 2988 rows, 77804 columns, 231944 nonzeros
Variable types: 0 continuous, 77804 integer (41104 binary)
Deterministic concurrent LP optimizer: primal and dual simplex
Showing primal log only...

Concurrent spin time: 0.00s

Solved with primal simplex

Root relaxation: cutoff, 53526 iterations, 2.29 seconds (2.11 work units)

    Nodes    |    Current Node    |     Objective Bounds      |     Work
 Expl Unexpl |  Obj  Depth IntInf | Incumbent    BestBd   Gap | It/Node Time

     0     0     cutoff    0      1468.00000 1468.00000  0.00%     -    2s

Explored 1 nodes (53526 simplex iterations) in 2.79 seconds (2.59 work units)
Thread count was 8 (of 8 available processors)

Solution count 1: 1468

Optimal solution found (tolerance 1.00e-04)
Best objective 1.468000000000e+03, best bound 1.468000000000e+03, gap 0.0000%

I would be more than glad to see if anyone can raise a better algorithm on this problem that beats mine. :grinning_face_with_smiling_eyes:

1 Like

The previous posts are based on the primary case where we minimize the number of raw rolls.
This is actually a degenerate case which leads to homogeneous blocks—meaning that only one subblock is needed when writting code.

Therefore, I extend the Cutting Stock Problem to a heterogeneous version—multiple different blocks—each being a (MIP) subproblem.

I’ve devised a satisfying (I think it’s pretty cool) algorithm which I’m going to call it a sandwiched MIP method (strengthening the original MIP formulation by sandwiching it).

Roughly speaking, Gurobi doesn’t perform well on the original compact formulation----say, converges to 1% after 2 hours—and become slower and slower due to huge size of branches.
While my algorithm spend nearly 1 hour to train a tight Lagrangian bound. Then after sandwiching, Gurobi returns me the OPTIMAL termination status with a 0.0098% gap.

Currently the main concern in my algorithm is a heuristic employed to identify some promising subproblem block—I’m unaware of if there is a good strategy. (x-ref Find the max element of a numeric vector iteratively with mask and early break)

My implementation is

code
import JuMP, Gurobi; GRB_ENV = Gurobi.Env();
import LinearAlgebra.⋅ as ⋅
import Random
Random.seed!(321); # ⚠️ when modify this, U should also modify hyperparam

I, J, w, d, W, c = let # 📄 load data
    δ = 1.7e-12 # used in test case generation
    I = 20; # [demand side] number of types of demand
    J = 400; # [resource side] number of raw rolls
    wl, wh = rand(13:δ:35), rand(77:δ:97)
    w = rand(wl:δ:wh, I) # width of each demand type
    dl, dh = rand(7:14), rand(57:77)
    d = rand(dl:dh, I) # quantity::Int of each demand type
    Wl, Wh = rand(70:δ:100), rand(400:δ:500)
    W = rand(Wl:δ:Wh, J) # width of each raw roll
    c = 0.01rand(0.99:δ:1.99, J) .* W # used cost of raw rolls (c)
    I, J, w, d, W, c
end;

if false # 📘 The original compact Mip formulation---as a benchmark
    Mip = JuMP.Model(() -> Gurobi.Optimizer(GRB_ENV)); 
    JuMP.@variable(Mip, 0 ≤ b[1:J] ≤ 1, Int);
    JuMP.@objective(Mip, Min, c ⋅ b);
    JuMP.@variable(Mip, 0 ≤ n[1:I, 1:J], Int);
    JuMP.@constraint(Mip, [j = 1:J], w ⋅ view(n, :, j) ≤ b[j]W[j]);
    # Note that the following complicating constraint 🟡 can also be a `==` one
    # We prefer write it as `≥` since the dual space will be halved
    JuMP.@constraint(Mip, [i = 1:I], sum(n[i, j] for j in 1:J) ≥ d[i]); # 🟡 [π ≥ 0]
    JuMP.unset_integer.(b)
    JuMP.unset_integer.(n)
    JuMP.optimize!(Mip) # 📘 [Natural LP Relaxation] Optimal objective 647.4510818
    JuMP.set_integer.(b)
    JuMP.set_integer.(n)
    JuMP.optimize!(Mip)
    # Gurobi's MIP logging
    # 6576  4630  647.68526   88  216  667.02107  647.47885  2.93%   6.3   10s
    # 1498325 1290411  650.72418 1504  203  654.50850  647.48232  1.07%  11.4 6482s
    # 📘 Conclusion: Gurobi spends 6482s to reach ub = 654.50850 | 647.48232 = lb 
end

# Dual Decomposition Assets
function Min_j_fixed_part(j)
    Min_j = JuMP.Model(() -> Gurobi.Optimizer(GRB_ENV))
    JuMP.@variable(Min_j, b_j, Bin);
    JuMP.@expression(Min_j, prim_obj_tbMin, c[j]b_j)
    JuMP.@variable(Min_j, 0 ≤ n_j[1:I], Int);
    JuMP.@constraint(Min_j, w ⋅ n_j ≤ W[j]b_j)
    return Min_j
end; function penalty(j, π, n_j::Array{JuMP.VariableRef})
    model = JuMP.owner_model(n_j[1])
    return JuMP.@expression(model, -π ⋅ n_j)
end; function penalty(j, π::Array{JuMP.VariableRef}, n_j)
    model = JuMP.owner_model(π[1])
    return JuMP.@expression(model, -π ⋅ n_j)
end; function draw_a_block!(bit_vec, Θ, Θ_old) # 🟠💡 This is an important heuristic.
    # I'm not aware of any better heuristic method to pick a promising block (that can
    # contribute a violating cut to the master)
    max_i, max_v = 0, -Inf
    for (i, undrawn) in enumerate(bit_vec)
        if undrawn
            v = Θ[i] - Θ_old[i] # 💡
            if max_v < v
                max_i, max_v = i, v
            end
        end
    end
    if max_i == 0 # cannot draw one due to all-fathomed
        return false, max_i
    else
        bit_vec[max_i] = false # indicating that max_i is drawn this time
        return true, max_i # can be drawn, a valid index
    end
end;

# build subproblems (MIPs)
Min_vec = [Min_j_fixed_part(j) for j = 1:J]; # The inner subproblems
# allocate these to collect columns identified for each block
n_cols = [[zeros(Int, I)] for j = 1:J]; pop!.(n_cols);
b_cols = [[false] for j = 1:J];         pop!.(b_cols);

# build master problem (LP)
an_UB = 685 # ⚠️ [hyperparam] a valid ub obtained from Gurobi's trial solve
out = JuMP.Model(() -> Gurobi.Optimizer(GRB_ENV)); # The outer dual problem
JuMP.@variable(out, 0 ≤ π[1:I]); JuMP.@variable(out, θ[1:J]);
JuMP.@expression(out, common, d ⋅ π);
JuMP.@expression(out, outer_obj_tbMax, common + sum(θ));
JuMP.@constraint(out, outer_obj_tbMax ≤ an_UB);
JuMP.@objective(out, Max, outer_obj_tbMax);

# The following main loop generates will a best possible dual variable via CTPLNs
JuMP.set_silent.(Min_vec); JuMP.optimize!.(Min_vec);
JuMP.set_silent(out); JuMP.optimize!(out); JuMP.assert_is_solved_and_feasible(out; allow_local = false)
Θ_old = JuMP.value.(θ); # 💡 need this for my heuristic
cnt_outˈs_cut = 0; # count the total number of cuts added to the outer model
cnt_futile_draw = 0; # a bad block-drawing strategy 🟠💡 might make this index large
t0 = time();
for COT = 5 * (0.5 .^ (0:16)) # Cut-Off Tolerance ⚠️ CANNOT by too small
    while true # work with the present COT
        exists_j_at_COT = false
        JuMP.optimize!(out); JuMP.assert_is_solved_and_feasible(out; allow_local = false)
        ub = JuMP.objective_bound(out)
        Π, Θ = JuMP.value.(π), JuMP.value.(θ)
        Θˈs_bitvec = trues(J) # 💡 need this for my heuristic
        while true # see if there is any block who can bring us a COT-violating cut
            still_has_undrawn, j = draw_a_block!(Θˈs_bitvec, Θ, Θ_old)
            still_has_undrawn || break
            Min_j = Min_vec[j]; prim_obj_tbMin, n_j = Min_j[:prim_obj_tbMin], Min_j[:n_j]
            JuMP.@objective(Min_j, Min, prim_obj_tbMin + penalty(j, Π, n_j)) # 🟢
            JuMP.optimize!(Min_j); JuMP.assert_is_solved_and_feasible(Min_j; allow_local = false)
            ObjVal_j = JuMP.objective_value(Min_j)
            if ObjVal_j + COT < Θ[j] # the hallmark of CTPLN's progress
                # Since we implement a single-cut algorithm, we can compress Logging into one line
                @info "#cut = $cnt_outˈs_cut, ub ▶ $ub | cut found at $j with vio = $(Θ[j] - ObjVal_j)"
                Θ_old = Θ # save the RHS, as it is to be updated
                Prim_obj_tbMin, N_j = JuMP.value(prim_obj_tbMin), JuMP.value.(n_j)
                JuMP.@constraint(out, θ[j] ≤ Prim_obj_tbMin + penalty(j, π, N_j)) # 🟢
                push!(n_cols[j], round.(Int, N_j))
                push!(b_cols[j], round(Bool, JuMP.value(Min_j[:b_j])))
                cnt_outˈs_cut += 1; exists_j_at_COT = true
                break # [Early Break] go back to outer model to update Π
            else
                cnt_futile_draw += 1
            end
        end
        if ! exists_j_at_COT
            @info "COT = $COT, elapsed $(time() - t0), futile $cnt_futile_draw, ub $ub ◀◀◀"
            break
        end
    end
end
@info "▶▶▶ total time elapsed is $(time() - t0)"
# retrieve that best dual solution
JuMP.optimize!(out); JuMP.assert_is_solved_and_feasible(out; allow_local = false); Π = JuMP.value.(π)

# Use the columns you had collected to build a restricted version of the original Mip
# The aim is to derive a good primal-side feasible solution
rMip = JuMP.Model(() -> Gurobi.Optimizer(GRB_ENV)); # restricted primal side Mip
JuMP.@variable(rMip, rb[1:J], Int); # `r` reads `restricted`
JuMP.@variable(rMip, rn[1:I, 1:J], Int);
let
    JuMP.@variable(rMip, lb[j = 1:J, v = 1:length(b_cols[j])] ≥ 0); # each v indexes a vertex
    JuMP.@variable(rMip, ln[j = 1:J, v = 1:length(n_cols[j])] ≥ 0); # `l` reads `lambda`
    JuMP.@constraint(rMip, [j = 1:J], sum(lb[j, :]) == 1);
    JuMP.@constraint(rMip, [j = 1:J], sum(ln[j, :]) == 1);
    JuMP.@constraint(rMip, [j = 1:J], rb[j] == sum(lb[j, v]b_cols[j][v]                 for v = 1:length(b_cols[j])));
    JuMP.@constraint(rMip, [i = 1:I, j = 1:J], rn[i, j] == sum(ln[j, v]n_cols[j][v][i]  for v = 1:length(n_cols[j])));
end;
JuMP.@constraint(rMip, [j = 1:J], w ⋅ view(rn, :, j) ≤ rb[j]W[j]);
JuMP.@constraint(rMip, [i = 1:I], sum(rn[i, j] for j in 1:J) ≥ d[i]); # 🟡
JuMP.@objective(rMip, Min, c ⋅ rb);
# This model may still take some time to solve to exact optimality, but it's a lot easier than the unrestricted original
# you may also interrupt early and be content with a good feasible (suboptimal) solution
JuMP.optimize!(rMip); # JuMP.assert_is_solved_and_feasible(rMip; allow_local = false)
b_start = JuMP.value.(rb);
n_start = JuMP.value.(rn);

# Finally build an sandwiched version of the original Mip such that
# it can carry on searching the global optimal solution to the original Mip
lbv = let
    lbv = zeros(J)
    for j = 1:J
        Min_j = Min_vec[j]
        b_j = Min_j[:b_j]
        n_j = Min_j[:n_j]
        JuMP.@objective(Min_j, Min, c[j]b_j - Π ⋅ n_j)
        JuMP.optimize!(Min_j); JuMP.assert_is_solved_and_feasible(Min_j; allow_local = false)
        lbv[j] = JuMP.objective_value(Min_j)
    end
    lbv
end;
swMip = JuMP.Model(() -> Gurobi.Optimizer(GRB_ENV)); # sandwiched Mip
JuMP.@variable(swMip, b[1:J], Bin);
JuMP.@objective(swMip, Min, c ⋅ b);
JuMP.@variable(swMip, 0 ≤ n[1:I, 1:J], Int);
JuMP.@constraint(swMip, [j = 1:J], w ⋅ view(n, :, j) ≤ b[j]W[j]);
JuMP.@constraint(swMip, [i = 1:I], sum(n[i, j] for j in 1:J) ≥ d[i]); # 🟡
JuMP.@constraint(swMip, [j = 1:J], c[j]b[j] - Π ⋅ view(n, :, j) ≥ lbv[j]); # 🥪 The lower bread of the sandwich---DWB cuts
JuMP.set_start_value.(b, b_start); JuMP.set_start_value.(n, n_start); # 🥪 The upper bread of the sandwich
JuMP.optimize!(swMip) # 🖥️ U can monitor Gurobi's logging

The previous posts are restricted to only 1-scenario.
In this post I add an axis of scenarios so that the original formulation becomes a Two-stage SIP.
The method to decompose it into (j, s)-blocks and calculating the corresponding Lagrangian bound is

code
import JuMP, Gurobi; GRB_ENV = Gurobi.Env();
import LinearAlgebra.⋅ as ⋅
import Random
seed = -1214589140429765082 # may need to cherry-pick
Random.seed!(seed)

J = 9; # number of resources
W = rand(9:49, J); # the sole width type of each resource
o = rand(30:0.01:70, J); # occupied cost
u = 7 * rand(0.75:0.01:1.25, J)/30 .* W; # unit price
L = rand(6:17, J); # purchasing num limit
I = 10; # number of customer
w = rand(7:0.7:24, I); # width needed
S = 2; # number of scenes
d = rand(6:35, I, S); # number needed
c = rand(0:0.001:0.1, I, J); # transport cost

if false 
    Xtn = JuMP.Model(() -> Gurobi.Optimizer(GRB_ENV)); # extensive formulation
    JuMP.@variable(Xtn, b_free[1:J]);
    JuMP.@variable(Xtn, m_free[1:J]);
    JuMP.@variable(Xtn, 0 ≤ b[1:J, 1:S] ≤ 1, Int); # use this site or not
    JuMP.@variable(Xtn, 0 ≤ m[1:J, 1:S], Int); # order an amount
    JuMP.@variable(Xtn, 0 ≤ n[1:I, 1:J, 1:S], Int); # number dispatched
    JuMP.@constraint(Xtn, [j = 1:J, s = 1:S], L[j]b[j, s] ≥ m[j, s]); # source num is limited
    JuMP.@constraint(Xtn, [j = 1:J, s = 1:S], m[j, s]W[j] ≥ w ⋅ view(n, :, j, s)); # source length is limited
    JuMP.@constraint(Xtn, [j = 1:J, s = 1:S], b_free[j] == b[j, s]); # [μ] 🟡
    JuMP.@constraint(Xtn, [j = 1:J, s = 1:S], m_free[j] == m[j, s]); # [π] 🟡
    JuMP.@constraint(Xtn, [i = 1:I, s = 1:S], sum(n[i, j, s] for j = 1:J) ≥ d[i, s]); # 🟡 [β ≥ 0] number of demand is met
    JuMP.@objective(Xtn, Min, sum(+(o ⋅ view(b, :, s)/S, u ⋅ view(m, :, s)/S, c ⋅ view(n, :, :, s)/S) for s = 1:S));
    JuMP.unset_integer.(b);
    JuMP.unset_integer.(m);
    JuMP.unset_integer.(n);
    # JuMP.set_silent(Xtn);
    JuMP.optimize!(Xtn); JuMP.assert_is_solved_and_feasible(Xtn)
    LP_val = JuMP.objective_value(Xtn);
    JuMP.set_integer.(b);
    JuMP.set_integer.(m);
    JuMP.set_integer.(n);
    JuMP.optimize!(Xtn); JuMP.assert_is_solved_and_feasible(Xtn)
    IP_val = JuMP.objective_value(Xtn);
    @info "LP $LP_val | $IP_val IP"
end

function penalty(j, s, β, π, μ, b, m, n::Array{JuMP.VariableRef}) # disaggregated to each js-block
    model = JuMP.owner_model(n[1])
    return JuMP.@expression(model, μ[j, s]b + π[j, s]m - sum(β[i, s] * n[i] for i = 1:I))
end; function penalty(j, s, β, π::Array{JuMP.VariableRef}, μ, b, m, n) # disaggregated to each js-block
    model = JuMP.owner_model(π[1])
    return JuMP.@expression(model, μ[j, s]b + π[j, s]m - sum(β[i, s] * n[i] for i = 1:I))
end; function Min_js_model(j, s)
    Min_js = JuMP.Model(() -> Gurobi.Optimizer(GRB_ENV))
    JuMP.@variable(Min_js, b, Bin)
    JuMP.@variable(Min_js, 0 ≤ m, Int)
    JuMP.@variable(Min_js, 0 ≤ n[1:I], Int)
    JuMP.@constraint(Min_js, L[j]b ≥ m)
    JuMP.@constraint(Min_js, W[j]m ≥ w ⋅ n);
    JuMP.@expression(Min_js, prim_obj_tbMin, +(o[j]b/S, u[j]m/S, sum(c[i, j]n[i]/S for i = 1:I)))
    return Min_js
end; function draw_a_block!(bitmat, Θ, Θ_old) # 💡 This is an important heuristic.
    max_j, max_s, max_v = 0, 0, -Inf
    J, S = size(bitmat)
    for j = Random.shuffle(1:J), s = Random.shuffle(1:S) # for j = 1:J, s = 1:S
        if bitmat[j, s] # undrawn yet
            v = Θ[j, s] - Θ_old[j, s] # 💡
            if max_v < v
                max_j, max_s, max_v = j, s, v
            end
        end
    end
    max_j == 0 && return false, max_j, max_s
    bitmat[max_j, max_s] = false # indicating it's drawn this time
    return true, max_j, max_s # can be drawn, a valid index
end;

an_UB = 1140.0 # ⚠️ [hyperparam] a valid ub obtained from Gurobi's trial solve
out = JuMP.Model(() -> Gurobi.Optimizer(GRB_ENV)); # The outer dual problem
JuMP.@variable(out, 0 ≤ β[1:I, 1:S]);
JuMP.@variable(out, μ[1:J, 1:S]);
JuMP.@variable(out, π[1:J, 1:S]);
JuMP.@constraint(out, [j = 1:J], sum(view(μ, j, :)) == 0); # 🟡
JuMP.@constraint(out, [j = 1:J], sum(view(π, j, :)) == 0); # 🟡
JuMP.@expression(out, common, β ⋅ d);
JuMP.@variable(out, θ[1:J, 1:S]);
JuMP.@expression(out, outer_obj_tbMax, common + sum(θ));
JuMP.@constraint(out, outer_obj_tbMax ≤ an_UB);
JuMP.@objective(out, Max, outer_obj_tbMax);

Min_mat = [Min_js_model(j, s) for j = 1:J, s = 1:S];
JuMP.set_silent.(Min_mat); JuMP.optimize!.(Min_mat);
JuMP.set_silent.(out); JuMP.optimize!(out); JuMP.assert_is_solved_and_feasible(out; allow_local = false)
Θ = JuMP.value.(θ); Θ_old = copy(Θ);
bitmat = trues(J, S); lbm = similar(θ, Float64);
t0 = time();
t_disposable = 0.;
for COT = 5 * (0.5 .^ (0:17))
    while true # work with the present COT
        JuMP.optimize!(out); JuMP.assert_is_solved_and_feasible(out; allow_local = false)
        common_value = JuMP.value(common)
        ub = JuMP.objective_bound(out)
        Θ_old .= Θ; Θ = JuMP.value.(θ)
        Β = JuMP.value.(β)
        Μ = JuMP.value.(μ)
        Π = JuMP.value.(π)
        bitmat .= true; exists_j_at_COT = false
        while true # see if there is any block who can bring us a COT-violating cut
            td0 = time()
            still_has_undrawn, j, s = draw_a_block!(bitmat, Θ, Θ_old)
            still_has_undrawn || break # go back to the outermost loop to update COT
            Min_js = Min_mat[j, s]
            prim_obj_tbMin = Min_js[:prim_obj_tbMin]
            n, b, m = Min_js[:n], Min_js[:b], Min_js[:m]
            JuMP.@objective(Min_js, Min, prim_obj_tbMin + penalty(j, s, Β, Π, Μ, b, m, n)) # 🟢
            JuMP.optimize!(Min_js); JuMP.assert_is_solved_and_feasible(Min_js; allow_local = false)
            lbm[j, s] = ObjVal = JuMP.objective_value(Min_js)
            overestimate = Θ[j, s] - ObjVal
            if overestimate > COT
                @info "ub ▶ $ub | cut found at ($j, $s) with vio = $overestimate > $COT = COT"
                Prim_obj_tbMin = JuMP.value(prim_obj_tbMin)
                N = JuMP.value.(n)
                M = JuMP.value.(m)
                B = JuMP.value.(b)
                JuMP.@constraint(out, θ[j, s] ≤ Prim_obj_tbMin + penalty(j, s, β, π, μ, B, M, N)) # 🟢
                exists_j_at_COT = true
                break # [Early Break] go back to outer model to update Β, Μ, Π
            end
            t_disposable += time() - td0
        end
        if ! exists_j_at_COT
            lb = common_value + sum(lbm)
            @info "COT = $COT finished, lb = $lb | $ub = ub ◀◀◀"
            break
        end
    end
end
t_total = time() - t0;
t_thin = t_total - t_disposable;
thin_ratio = round(t_thin / t_total; digits = 2);
println("t_thin = $t_thin < t_total = $t_total (ratio = $thin_ratio)")