Документация Engee

Разложение Бендерса

Это руководство было изначально предоставлено Шувомой Дас Гуптой (Shuvomoy Das Gupta).

В этом руководстве описывается, как реализовать разложение Бендерса в JuMP. В нем используются следующие пакеты.

using JuMP
import GLPK
import Printf

Теория

Разложение Бендерса — это полезный алгоритм для решения задач выпуклой оптимизации с большим числом переменных. Лучше всего оно работает, когда большую задачу можно разложить на две (или более) задачи меньшего размера, которые по отдельности решаются гораздо проще. В этом руководстве демонстрируется разложение Бендера на примере следующей частично целочисленной линейной программы:

где , , и  — это множество целых чисел.

Любую задачу частично целочисленного программирования можно записать в приведенной выше форме.

Если целочисленных переменных относительно немного, а непрерывных гораздо больше, то может быть полезно разделить общую задачу на небольшую задачу только с целочисленными переменными и линейную программу только с непрерывными переменными. Скорее всего, такую линейную программу будет гораздо проще решить изолированно, чем в составе полной частично целочисленной линейной программы.

Например, если бы было известно допустимое решение для , решение для можно было бы получить так:

где  — двойственная переменная, связанная с ограничениями. Поскольку это линейная программа, ее легко решить.

Заменив компонент целевой функции в исходной задаче на , получим следующее.

Похоже, эту задачу решить гораздо проще, но сначала нужно еще кое-что сделать с .

Так как  — это константа, используемая в правой части ограничений,  — это выпуклая функция относительно , а двойственную переменную можно умножить на , чтобы получить субградиент относительно . Следовательно, если есть вариант решения , можно решить и получить допустимый двойственный вектор . Используя эти значения, можно построить аппроксимацию в виде ряда Тейлора первого порядка в окрестности точки :

Из выпуклости следует, что это неравенство верно для всех , и такие неравенства называются разрезами.

Разложение Бендерса — это итеративный метод, который заменяет новой переменной решения и аппроксимирует ее снизу с помощью разрезов:

Эта целочисленная программа называется подзадачей первой стадии.

Для генерирования разрезов мы решаем , чтобы получить вариант решения первой стадии , а затем используем это решение для решения . Затем, используя оптимальное целевое значение и двойственное решение из , мы добавляем новый разрез для формирования и повторяем процесс.

Границы

В силу выпуклости мы знаем, что для всех . Поэтому оптимальное целевое значение обеспечивает допустимую нижнюю границу целевого значения полной задачи. Кроме того, если взять допустимое решение для из задачи первой стадии, то будет допустимой верхней границей для целевого значения полной задачи.

В разложении Бендерса нижняя и верхняя границы применяются для определения момента нахождения глобального оптимального решения.

Входные данные

В качестве примера для этого руководства используются входные данные со страницы 139 книги Garfinkel, R. & Nemhauser, G. L. Integer programming (Wiley, 1972).

c_1 = [1, 4]
c_2 = [2, 3]
dim_x = length(c_1)
dim_y = length(c_2)
b = [-2; -3]
A_1 = [1 -3; -1 -3]
A_2 = [1 -2; -1 -1]
M = -1000;

Итеративный метод

Это базовая реализация для учебных целей. Мы не стали рассматривать разрезы допустимости Бендерса или различные вычислительные приемы, необходимые для создания эффективной реализации для масштабных задач. Описание одной из оптимизаций, помогающих улучшить время вычисления см. в разделе In-place iterative method.

Начнем с формулирования подзадачи первой стадии.

model = Model(GLPK.Optimizer)
@variable(model, x[1:dim_x] >= 0, Int)
@variable(model, θ >= M)
@objective(model, Min, c_1' * x + θ)
print(model)
Min x[1] + 4 x[2] + θ
Subject to
 x[1] ≥ 0
 x[2] ≥ 0
 θ ≥ -1000
 x[1] integer
 x[2] integer

Для следующего шага нам понадобится функция, которая принимает вариант решения первой стадии x и возвращает оптимальное решение подзадачи второй стадии:

function solve_subproblem(x)
    model = Model(GLPK.Optimizer)
    @variable(model, y[1:dim_y] >= 0)
    con = @constraint(model, A_2 * y .<= b - A_1 * x)
    @objective(model, Min, c_2' * y)
    optimize!(model)
    @assert is_solved_and_feasible(model; dual = true)
    return (obj = objective_value(model), y = value.(y), π = dual.(con))
end
solve_subproblem (generic function with 1 method)

Обратите внимание, что solve_subproblem возвращает кортеж NamedTuple целевого значения, оптимальное прямое решение для y и оптимальное двойственное решение для π.

Все почти готово к циклу оптимизации, но сначала вот полезная функция для ведения журнала:

function print_iteration(k, args...)
    f(x) = Printf.@sprintf("%12.4e", x)
    println(lpad(k, 9), " ", join(f.(args), " "))
    return
end
print_iteration (generic function with 1 method)

Кроме того, необходимо установить ограничение на количество итераций до завершения:

MAXIMUM_ITERATIONS = 100
100

И указать способ проверки приближения к нижней и верхней границам:

ABSOLUTE_OPTIMALITY_GAP = 1e-6
1.0e-6

Теперь можно выполнить итерации разложения Бендерса:

println("Iteration  Lower Bound  Upper Bound          Gap")
for k in 1:MAXIMUM_ITERATIONS
    optimize!(model)
    @assert is_solved_and_feasible(model)
    lower_bound = objective_value(model)
    x_k = value.(x)
    ret = solve_subproblem(x_k)
    upper_bound = c_1' * x_k + ret.obj
    gap = (upper_bound - lower_bound) / upper_bound
    print_iteration(k, lower_bound, upper_bound, gap)
    if gap < ABSOLUTE_OPTIMALITY_GAP
        println("Terminating with the optimal solution")
        break
    end
    cut = @constraint(model, θ >= ret.obj + -ret.π' * A_1 * (x .- x_k))
    @info "Adding the cut $(cut)"
end
Iteration  Lower Bound  Upper Bound          Gap
        1  -1.0000e+03   7.6667e+00   1.3143e+02
[ Info: Adding the cut 2 x[1] + 8 x[2] + θ ≥ 7.666666666666666
        2  -4.9600e+02   1.2630e+03   1.3927e+00
[ Info: Adding the cut -1.5 x[1] + 4.5 x[2] + θ ≥ 3
        3  -1.0800e+02   8.8800e+02   1.1216e+00
[ Info: Adding the cut θ ≥ 0
        4   4.0000e+00   4.0000e+00   0.0000e+00
Terminating with the optimal solution

Наконец, можно получить оптимальное решение:

optimize!(model)
@assert is_solved_and_feasible(model)
x_optimal = value.(x)
2-element Vector{Float64}:
 0.0
 1.0
optimal_ret = solve_subproblem(x_optimal)
y_optimal = optimal_ret.y
2-element Vector{Float64}:
 0.0
 0.0

Метод обратного вызова

В разделе Итеративный метод разложение Бендерса было реализовано посредством цикла. В каждой итерации повторно решалась подзадача первой стадии для получения варианта решения. Однако современные решатели MILP, такие как CPLEX, Gurobi и GLPK, предоставляют обратные вызовы отложенных ограничений, которые позволяют добавлять новые разрезы во время работы решателя. Это может быть эффективнее итеративного метода, так как позволяет избежать повторных действий, например нахождения решения для корневого узла программы MILP первой стадии в каждой итерации.

Дополнительные сведения об обратных вызовах см. на странице Обратные вызовы, не зависящие от решателя.

Создаем ту же подзадачу первой стадии, что и ранее:

lazy_model = Model(GLPK.Optimizer)
@variable(lazy_model, x[1:dim_x] >= 0, Int)
@variable(lazy_model, θ >= M)
@objective(lazy_model, Min, c_1' * x + θ)
print(lazy_model)
Min x[1] + 4 x[2] + θ
Subject to
 x[1] ≥ 0
 x[2] ≥ 0
 θ ≥ -1000
 x[1] integer
 x[2] integer

Отличие в том, что мы пишем функцию обратного вызова, а не цикл:

number_of_subproblem_solves = 0
function my_callback(cb_data)
    status = callback_node_status(cb_data, lazy_model)
    if status != MOI.CALLBACK_NODE_STATUS_INTEGER
        # Добавляем ограничение только в том случае, если `x` является целочисленным допустимым решением
        return
    end
    x_k = callback_value.(cb_data, x)
    θ_k = callback_value(cb_data, θ)
    global number_of_subproblem_solves += 1
    ret = solve_subproblem(x_k)
    if θ_k < (ret.obj - 1e-6)
        # Добавляем ограничение только в том случае, если θ_k нарушает ограничение
        cut = @build_constraint(θ >= ret.obj + -ret.π' * A_1 * (x .- x_k))
        MOI.submit(lazy_model, MOI.LazyConstraint(cb_data), cut)
    end
    return
end

set_attribute(lazy_model, MOI.LazyConstraintCallback(), my_callback)

Теперь при вызове optimize! выполняется обратный вызов:

optimize!(lazy_model)
@assert is_solved_and_feasible(lazy_model)

Для этой модели алгоритм обратного вызова потребовал большего количества решений подзадачи:

number_of_subproblem_solves
341

Однако для более крупных задач можно ожидать, что алгоритм обратного вызова будет эффективнее итеративного.

Наконец, можно получить оптимальное решение:

x_optimal = value.(x)
2-element Vector{Float64}:
 0.0
 1.0
optimal_ret = solve_subproblem(x_optimal)
y_optimal = optimal_ret.y
2-element Vector{Float64}:
 0.0
 0.0

Итеративный метод, выполняемый на месте

У нашей реализации итеративного метода есть проблема: при каждом решении подзадачи ее приходится создавать заново с нуля. Это расходует ресурсы и может стать узким местом в процессе решения. Мы можем улучшить реализацию, повторно используя подзадачу при каждом решении.

Сначала, как обычно, создаем задачу первой стадии:

model = Model(GLPK.Optimizer)
@variable(model, x[1:dim_x] >= 0, Int)
@variable(model, θ >= M)
@objective(model, Min, c_1' * x + θ)
print(model)
Min x[1] + 4 x[2] + θ
Subject to
 x[1] ≥ 0
 x[2] ≥ 0
 θ ≥ -1000
 x[1] integer
 x[2] integer

Затем вместо построения подзадачи в функции мы создаем ее один раз здесь:

subproblem = Model(GLPK.Optimizer)
@variable(subproblem, x_copy[1:dim_x])
@variable(subproblem, y[1:dim_y] >= 0)
@constraint(subproblem, A_1 * x_copy + A_2 * y .<= b)
@objective(subproblem, Min, c_2' * y)
print(subproblem)
Min 2 y[1] + 3 y[2]
Subject to
 x_copy[1] - 3 x_copy[2] + y[1] - 2 y[2] ≤ -2
 -x_copy[1] - 3 x_copy[2] - y[1] - y[2] ≤ -3
 y[1] ≥ 0
 y[2] ≥ 0

Эта формулировка немного отличается. Мы включили копию переменных x, x_copy, и использовали x_copy в левой части ограничений.

Функция для решения подзадачи также немного отличается. Сначала нам нужно зафиксировать переменные x_copy в значении x из задачи первой стадии, а затем вычислить двойственное решение, используя reduced_cost для x_copy, а не двойственное решение для линейных ограничений:

function solve_subproblem(model, x)
    fix.(model[:x_copy], x)
    optimize!(model)
    @assert is_solved_and_feasible(model; dual = true)
    return (
        obj = objective_value(model),
        y = value.(model[:y]),
        π = reduced_cost.(model[:x_copy]),
    )
end
solve_subproblem (generic function with 2 methods)

Теперь можно выполнить итерации разложения Бендерса на месте, но на этот раз разрез вычисляется немного иначе. Так как мы использовали reduced_cost для переменных x_copy, значение π является допустимым субградиентом целевой функции subproblem относительно x. Поэтому двойственные решения не нужно умножать на -A_1, и для разреза используется ret.π' вместо -ret.π' * A_1:

println("Iteration  Lower Bound  Upper Bound          Gap")
for k in 1:MAXIMUM_ITERATIONS
    optimize!(model)
    @assert is_solved_and_feasible(model)
    lower_bound = objective_value(model)
    x_k = value.(x)
    ret = solve_subproblem(subproblem, x_k)
    upper_bound = c_1' * x_k + ret.obj
    gap = (upper_bound - lower_bound) / upper_bound
    print_iteration(k, lower_bound, upper_bound, gap)
    if gap < ABSOLUTE_OPTIMALITY_GAP
        println("Terminating with the optimal solution")
        break
    end
    cut = @constraint(model, θ >= ret.obj + ret.π' * (x .- x_k))
    @info "Adding the cut $(cut)"
end
Iteration  Lower Bound  Upper Bound          Gap
        1  -1.0000e+03   7.6667e+00   1.3143e+02
[ Info: Adding the cut 2 x[1] + 8 x[2] + θ ≥ 7.666666666666666
        2  -4.9600e+02   1.2630e+03   1.3927e+00
[ Info: Adding the cut -1.5 x[1] + 4.5 x[2] + θ ≥ 3
        3  -1.0800e+02   8.8800e+02   1.1216e+00
[ Info: Adding the cut θ ≥ 0
        4   4.0000e+00   4.0000e+00   0.0000e+00
Terminating with the optimal solution

Наконец, можно получить оптимальное решение:

optimize!(model)
@assert is_solved_and_feasible(model)
x_optimal = value.(x)
2-element Vector{Float64}:
 0.0
 1.0
optimal_ret = solve_subproblem(subproblem, x_optimal)
y_optimal = optimal_ret.y
2-element Vector{Float64}:
 0.0
 0.0

Для более крупных задач преимущество повторного использования одной той же подзадачи и отсутствия необходимости умножать двойственные решения на A_1 в коэффициенте разреза обычно перевешивает затраты, связанные с необходимостью копировать переменные x в подзадаче.

Дополнительное преимущество заключается в том, что, поскольку в разрезе больше не требуется явное представление A_1, можно строить формулировки model и subproblem с использованием произвольного синтаксиса JuMP; они не обязательно должны быть в матричной форме.