Разложение Бендерса
Это руководство было изначально предоставлено Шувомой Дас Гуптой (Shuvomoy Das Gupta).
В этом руководстве описывается, как реализовать разложение Бендерса в JuMP. В нем используются следующие пакеты.
using JuMP
import GLPK
import Printf
Теория
Разложение Бендерса — это полезный алгоритм для решения задач выпуклой оптимизации с большим числом переменных. Лучше всего оно работает, когда большую задачу можно разложить на две (или более) задачи меньшего размера, которые по отдельности решаются гораздо проще. В этом руководстве демонстрируется разложение Бендера на примере следующей частично целочисленной линейной программы:
где , , и — это множество целых чисел.
Любую задачу частично целочисленного программирования можно записать в приведенной выше форме.
Если целочисленных переменных относительно немного, а непрерывных гораздо больше, то может быть полезно разделить общую задачу на небольшую задачу только с целочисленными переменными и линейную программу только с непрерывными переменными. Скорее всего, такую линейную программу будет гораздо проще решить изолированно, чем в составе полной частично целочисленной линейной программы.
Например, если бы было известно допустимое решение для , решение для можно было бы получить так:
где — двойственная переменная, связанная с ограничениями. Поскольку это линейная программа, ее легко решить.
Заменив компонент целевой функции в исходной задаче на , получим следующее.
Похоже, эту задачу решить гораздо проще, но сначала нужно еще кое-что сделать с .
Так как — это константа, используемая в правой части ограничений, — это выпуклая функция относительно , а двойственную переменную можно умножить на , чтобы получить субградиент относительно . Следовательно, если есть вариант решения , можно решить и получить допустимый двойственный вектор . Используя эти значения, можно построить аппроксимацию в виде ряда Тейлора первого порядка в окрестности точки :
Из выпуклости следует, что это неравенство верно для всех , и такие неравенства называются разрезами.
Разложение Бендерса — это итеративный метод, который заменяет новой переменной решения и аппроксимирует ее снизу с помощью разрезов:
Эта целочисленная программа называется подзадачей первой стадии.
Для генерирования разрезов мы решаем , чтобы получить вариант решения первой стадии , а затем используем это решение для решения . Затем, используя оптимальное целевое значение и двойственное решение из , мы добавляем новый разрез для формирования и повторяем процесс.
Границы
В силу выпуклости мы знаем, что для всех . Поэтому оптимальное целевое значение обеспечивает допустимую нижнюю границу целевого значения полной задачи. Кроме того, если взять допустимое решение для из задачи первой стадии, то будет допустимой верхней границей для целевого значения полной задачи.
В разложении Бендерса нижняя и верхняя границы применяются для определения момента нахождения глобального оптимального решения.
Итеративный метод
Это базовая реализация для учебных целей. Мы не стали рассматривать разрезы допустимости Бендерса или различные вычислительные приемы, необходимые для создания эффективной реализации для масштабных задач. Описание одной из оптимизаций, помогающих улучшить время вычисления см. в разделе 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; они не обязательно должны быть в матричной форме.