Задача о сетевом потоке нескольких видов товаров
Формулировка
Задача о сетевом потоке нескольких видов товаров является расширением задачи из раздела The multi-commodity flow problem, где вместо двудольного графа узлов предложения и спроса граф может содержать набор узлов , каждый из которых имеет (возможно, нулевой) потенциал предложения и (возможно, нулевой) спрос для каждого вида товара . Узлы соединены набором ребер , которые имеют стоимость транспортировки и общую пропускную способность .
Наша задача — выбрать оптимальный объем поставки для каждого узла , а также оптимальный вариант перевозки с минимальной общей стоимостью.
Математическая формулировка:
Цель данного руководства — продемонстрировать построение этой модели с использованием алгебры отношений вместо прямого преобразования математических операций суммирования в код.
Данные
Для целей данного руководства в репозитории JuMP есть пример базы данных под названием commodity_nz.db:
filename = joinpath(@__DIR__, "commodity_nz.db");
Для локального выполнения скачайте файл commodity_nz.db и соответствующим образом измените значение filename.
Загрузите базу данных с помощью SQLite.DB:
db = SQLite.DB(filename)
SQLite.DB("/home/docs/julia_docs/JuMP.jl-master/docs/build/tutorials/linear/commodity_nz.db")
Схему базы данных можно быстро просмотреть с помощью SQLite.tables:
SQLite.tables(db)
4-element Vector{SQLite.DBTable}:
SQLite.DBTable("products", Tables.Schema:
:product Union{Missing, String}
:cost_per_km Union{Missing, Float64})
SQLite.DBTable("shipping", Tables.Schema:
:origin Union{Missing, String}
:destination Union{Missing, String}
:product Union{Missing, String}
:distance_km Union{Missing, Float64})
SQLite.DBTable("supply", Tables.Schema:
:origin Union{Missing, String}
:product Union{Missing, String}
:capacity Union{Missing, Float64}
:cost Union{Missing, Float64})
SQLite.DBTable("demand", Tables.Schema:
:destination Union{Missing, String}
:product Union{Missing, String}
:demand Union{Missing, Float64})
Взаимодействие с базой данных осуществляется путем выполнения запросов и загрузки результатов в DataFrame:
function get_table(db, table)
query = DBInterface.execute(db, "SELECT * FROM $table")
return DataFrames.DataFrame(query)
end
get_table (generic function with 1 method)
Таблица shipping содержит набор ребер графа и их расстояний:
df_shipping = get_table(db, "shipping")
| Row | origin | destination | product | distance_km |
|---|---|---|---|---|
| String | String | String | Float64 | |
| 1 | auckland | waikato | milk | 112.0 |
| 2 | auckland | tauranga | milk | 225.0 |
| 3 | auckland | christchurch | milk | 1070.0 |
| 4 | waikato | auckland | milk | 112.0 |
| 5 | waikato | tauranga | milk | 107.0 |
| 6 | waikato | wellington | milk | 392.0 |
| 7 | tauranga | auckland | milk | 225.0 |
| 8 | tauranga | waikato | milk | 107.0 |
| 9 | christchurch | auckland | milk | 1070.0 |
| 10 | auckland | waikato | kiwifruit | 112.0 |
| 11 | auckland | christchurch | kiwifruit | 1070.0 |
| 12 | waikato | auckland | kiwifruit | 112.0 |
| 13 | waikato | wellington | kiwifruit | 392.0 |
| 14 | tauranga | auckland | kiwifruit | 225.0 |
| 15 | tauranga | waikato | kiwifruit | 107.0 |
| 16 | christchurch | auckland | kiwifruit | 1070.0 |
В таблице products указана стоимость доставки за километр для каждого товара:
df_products = get_table(db, "products")
| Row | product | cost_per_km |
|---|---|---|
| String | Float64 | |
| 1 | milk | 0.001 |
| 2 | kiwifruit | 0.01 |
Таблица supply содержит потенциал предложения каждого узла, а также стоимость:
df_supply = get_table(db, "supply")
| Row | origin | product | capacity | cost |
|---|---|---|---|---|
| String | String | Float64 | Float64 | |
| 1 | waikato | milk | 10.0 | 0.5 |
| 2 | tauranga | milk | 6.0 | 1.0 |
| 3 | tauranga | kiwifruit | 26.0 | 1.0 |
| 4 | christchurch | milk | 10.0 | 0.6 |
Таблица demand содержит спрос каждого узла:
df_demand = get_table(db, "demand")
| Row | destination | product | demand |
|---|---|---|---|
| String | String | Float64 | |
| 1 | auckland | milk | 16.0 |
| 2 | auckland | kiwifruit | 16.0 |
| 3 | tauranga | milk | 2.0 |
| 4 | tauranga | kiwifruit | 2.0 |
| 5 | wellington | milk | 2.0 |
| 6 | wellington | kiwifruit | 2.0 |
| 7 | christchurch | milk | 4.0 |
| 8 | christchurch | kiwifruit | 4.0 |
Формулировка на языке JuMP
Начнем с создания модели и переменных решения:
model = Model(HiGHS.Optimizer)
set_silent(model)
Для принятия решений о транспортировке мы создадим в df_shipping новый столбец под названием x_flow с одной неотрицательной переменной решения для каждой строки:
df_shipping.x_flow = @variable(model, x[1:size(df_shipping, 1)] >= 0)
df_shipping
| Row | origin | destination | product | distance_km | x_flow |
|---|---|---|---|---|---|
| String | String | String | Float64 | GenericV… | |
| 1 | auckland | waikato | milk | 112.0 | x |
| 2 | auckland | tauranga | milk | 225.0 | x |
| 3 | auckland | christchurch | milk | 1070.0 | x |
| 4 | waikato | auckland | milk | 112.0 | x |
| 5 | waikato | tauranga | milk | 107.0 | x |
| 6 | waikato | wellington | milk | 392.0 | x |
| 7 | tauranga | auckland | milk | 225.0 | x |
| 8 | tauranga | waikato | milk | 107.0 | x |
| 9 | christchurch | auckland | milk | 1070.0 | x |
| 10 | auckland | waikato | kiwifruit | 112.0 | x |
| 11 | auckland | christchurch | kiwifruit | 1070.0 | x |
| 12 | waikato | auckland | kiwifruit | 112.0 | x |
| 13 | waikato | wellington | kiwifruit | 392.0 | x |
| 14 | tauranga | auckland | kiwifruit | 225.0 | x |
| 15 | tauranga | waikato | kiwifruit | 107.0 | x |
| 16 | christchurch | auckland | kiwifruit | 1070.0 | x |
Для узлов предложения мы добавим переменную в каждую строку, а затем установим верхнюю границу равной потенциалу предложения узла:
df_supply.x_supply = @variable(model, s[1:size(df_supply, 1)] >= 0)
set_upper_bound.(df_supply.x_supply, df_supply.capacity)
df_supply
| Row | origin | product | capacity | cost | x_supply |
|---|---|---|---|---|---|
| String | String | Float64 | Float64 | GenericV… | |
| 1 | waikato | milk | 10.0 | 0.5 | s |
| 2 | tauranga | milk | 6.0 | 1.0 | s |
| 3 | tauranga | kiwifruit | 26.0 | 1.0 | s |
| 4 | christchurch | milk | 10.0 | 0.6 | s |
Наша цель — минимизировать стоимость доставки и стоимость поставки. Чтобы вычислить стоимость потока, необходимо объединить таблицу доставки, которая содержит distance_km, с таблицей товаров, которая содержит cost_per_km:
df_cost = DataFrames.leftjoin(df_shipping, df_products; on = [:product])
df_cost.flow_cost = df_cost.cost_per_km .* df_cost.distance_km
df_cost
| Row | origin | destination | product | distance_km | x_flow | cost_per_km | flow_cost |
|---|---|---|---|---|---|---|---|
| String | String | String | Float64 | GenericV… | Float64? | Float64 | |
| 1 | auckland | waikato | milk | 112.0 | x | 0.001 | 0.112 |
| 2 | auckland | tauranga | milk | 225.0 | x | 0.001 | 0.225 |
| 3 | auckland | christchurch | milk | 1070.0 | x | 0.001 | 1.07 |
| 4 | waikato | auckland | milk | 112.0 | x | 0.001 | 0.112 |
| 5 | waikato | tauranga | milk | 107.0 | x | 0.001 | 0.107 |
| 6 | waikato | wellington | milk | 392.0 | x | 0.001 | 0.392 |
| 7 | tauranga | auckland | milk | 225.0 | x | 0.001 | 0.225 |
| 8 | tauranga | waikato | milk | 107.0 | x | 0.001 | 0.107 |
| 9 | christchurch | auckland | milk | 1070.0 | x | 0.001 | 1.07 |
| 10 | auckland | waikato | kiwifruit | 112.0 | x | 0.01 | 1.12 |
| 11 | auckland | christchurch | kiwifruit | 1070.0 | x | 0.01 | 10.7 |
| 12 | waikato | auckland | kiwifruit | 112.0 | x | 0.01 | 1.12 |
| 13 | waikato | wellington | kiwifruit | 392.0 | x | 0.01 | 3.92 |
| 14 | tauranga | auckland | kiwifruit | 225.0 | x | 0.01 | 2.25 |
| 15 | tauranga | waikato | kiwifruit | 107.0 | x | 0.01 | 1.07 |
| 16 | christchurch | auckland | kiwifruit | 1070.0 | x | 0.01 | 10.7 |
Затем с помощью линейной алгебры можно вычислить внутреннее произведение двух столбцов:
@objective(
model,
Min,
df_cost.flow_cost' * df_shipping.x_flow +
df_supply.cost' * df_supply.x_supply
);
Для пропускной способности потока по каждому ребру мы используем DataFrames.groupby для разделения переменных потока на основе :origin и :destination, а затем ограничим их сумму так, чтобы она была меньше фиксированной пропускной способности.
capacity = 30
for df in DataFrames.groupby(df_shipping, [:origin, :destination])
@constraint(model, sum(df.x_flow) <= capacity)
end
Для каждого узла графа необходимо вычислить ограничение массового баланса, которое гласит, что для каждого товара сумма предложения и потока в узел минус поток из узла равна спросу.
Выражение для потока из каждого узла можно вычислить, используя DataFrames.groupby применительно к столбцам origin и product таблицы df_shipping:
df_flow_out = DataFrames.DataFrame(
(node = i.origin, product = i.product, x_flow_out = sum(df.x_flow)) for
(i, df) in pairs(DataFrames.groupby(df_shipping, [:origin, :product]))
)
| Row | node | product | x_flow_out |
|---|---|---|---|
String |
String |
AffExpr |
|
1 |
auckland |
milk |
x[1] + x[2] + x[3] |
2 |
waikato |
milk |
x[4] + x[5] + x[6] |
3 |
tauranga |
milk |
x[7] + x[8] |
4 |
christchurch |
milk |
x[9] |
5 |
auckland |
kiwifruit |
x[10] + x[11] |
6 |
waikato |
kiwifruit |
x[12] + x[13] |
7 |
tauranga |
kiwifruit |
x[14] + x[15] |
8 |
christchurch |
kiwifruit |
x[16] |
Выражение для потока в каждый узел можно вычислить, используя DataFrames.groupby применительно к столбцам destination и product таблицы df_shipping:
df_flow_in = DataFrames.DataFrame(
(node = i.destination, product = i.product, x_flow_in = sum(df.x_flow))
for (i, df) in
pairs(DataFrames.groupby(df_shipping, [:destination, :product]))
)
| Row | node | product | x_flow_in |
|---|---|---|---|
String |
String |
AffExpr |
|
1 |
waikato |
milk |
x[1] + x[8] |
2 |
tauranga |
milk |
x[2] + x[5] |
3 |
christchurch |
milk |
x[3] |
4 |
auckland |
milk |
x[4] + x[7] + x[9] |
5 |
wellington |
milk |
x[6] |
6 |
waikato |
kiwifruit |
x[10] + x[15] |
7 |
christchurch |
kiwifruit |
x[11] |
8 |
auckland |
kiwifruit |
x[12] + x[14] + x[16] |
9 |
wellington |
kiwifruit |
x[13] |
Объединить таблицы можно с помощью DataFrames.outerjoin. Здесь приходится использовать outerjoin, так как некоторые строки могут быть пропущены.
df = DataFrames.outerjoin(df_flow_in, df_flow_out; on = [:node, :product])
| Row | node | product | x_flow_in | x_flow_out |
|---|---|---|---|---|
String |
String |
AffExpr? |
AffExpr? |
|
1 |
waikato |
milk |
x[1] + x[8] |
x[4] + x[5] + x[6] |
2 |
tauranga |
milk |
x[2] + x[5] |
x[7] + x[8] |
3 |
christchurch |
milk |
x[3] |
x[9] |
4 |
auckland |
milk |
x[4] + x[7] + x[9] |
x[1] + x[2] + x[3] |
5 |
waikato |
kiwifruit |
x[10] + x[15] |
x[12] + x[13] |
6 |
christchurch |
kiwifruit |
x[11] |
x[16] |
7 |
auckland |
kiwifruit |
x[12] + x[14] + x[16] |
x[10] + x[11] |
8 |
wellington |
milk |
x[6] |
missing |
9 |
wellington |
kiwifruit |
x[13] |
missing |
10 |
tauranga |
kiwifruit |
missing |
x[14] + x[15] |
Далее необходимо присоединить столбец предложения:
df = DataFrames.leftjoin(
df,
DataFrames.select(df_supply, [:origin, :product, :x_supply]);
on = [:node => :origin, :product],
)
| Row | node | product | x_flow_in | x_flow_out | x_supply |
|---|---|---|---|---|---|
String |
String |
AffExpr? |
AffExpr? |
GenericV…? |
|
1 |
waikato |
milk |
x[1] + x[8] |
x[4] + x[5] + x[6] |
s[1] |
2 |
tauranga |
milk |
x[2] + x[5] |
x[7] + x[8] |
s[2] |
3 |
christchurch |
milk |
x[3] |
x[9] |
s[4] |
4 |
tauranga |
kiwifruit |
missing |
x[14] + x[15] |
s[3] |
5 |
auckland |
milk |
x[4] + x[7] + x[9] |
x[1] + x[2] + x[3] |
missing |
6 |
waikato |
kiwifruit |
x[10] + x[15] |
x[12] + x[13] |
missing |
7 |
christchurch |
kiwifruit |
x[11] |
x[16] |
missing |
8 |
auckland |
kiwifruit |
x[12] + x[14] + x[16] |
x[10] + x[11] |
missing |
9 |
wellington |
milk |
x[6] |
missing |
missing |
10 |
wellington |
kiwifruit |
x[13] |
missing |
missing |
и столбец спроса:
df = DataFrames.leftjoin(
df,
DataFrames.select(df_demand, [:destination, :product, :demand]);
on = [:node => :destination, :product],
)
| Row | node | product | x_flow_in | x_flow_out | x_supply | demand |
|---|---|---|---|---|---|---|
| String | String | AffExpr? | AffExpr? | GenericV…? | Float64? | |
| 1 | tauranga | milk | x[2] + x | x[7] + x | s | 2.0 |
| 2 | christchurch | milk | x | x | s | 4.0 |
| 3 | tauranga | kiwifruit | missing | x[14] + x | s | 2.0 |
| 4 | auckland | milk | x[4] + x[7] + x | x[1] + x[2] + x | missing | 16.0 |
| 5 | christchurch | kiwifruit | x | x | missing | 4.0 |
| 6 | auckland | kiwifruit | x[12] + x[14] + x | x[10] + x | missing | 16.0 |
| 7 | wellington | milk | x | missing | missing | 2.0 |
| 8 | wellington | kiwifruit | x | missing | missing | 2.0 |
| 9 | waikato | milk | x[1] + x | x[4] + x[5] + x | s | missing |
| 10 | waikato | kiwifruit | x[10] + x | x[12] + x | missing | missing |
Теперь мы готовы добавить ограничение массового баланса. Так как в некоторых строках есть значения missing, необходимо использовать coalesce для преобразования missing в числовое значение:
@constraint(
model,
[r in eachrow(df)],
coalesce(r.x_supply, 0.0) + coalesce(r.x_flow_in, 0.0) -
coalesce(r.x_flow_out, 0.0) == coalesce(r.demand, 0.0),
);
Решение
Наконец, можно оптимизировать модель:
optimize!(model)
Test.@test is_solved_and_feasible(model)
solution_summary(model)
* Solver : HiGHS
* Status
Result count : 1
Termination status : OPTIMAL
Message from the solver:
"kHighsModelStatusOptimal"
* Candidate solution (result #1)
Primal status : FEASIBLE_POINT
Dual status : FEASIBLE_POINT
Objective value : 1.43228e+02
Objective bound : 0.00000e+00
Relative gap : Inf
Dual objective value : 1.43228e+02
* Work counters
Solve time (sec) : 4.22001e-04
Simplex iterations : 8
Barrier iterations : 0
Node count : -1
Обновляем решение в объектах DataFrame:
df_shipping.x_flow = value.(df_shipping.x_flow)
df_supply.x_supply = value.(df_supply.x_supply);
И выводим оптимальное решение для потоков:
DataFrames.select(
filter!(row -> row.x_flow > 0.0, df_shipping),
[:origin, :destination, :product, :x_flow],
)
| Row | origin | destination | product | x_flow |
|---|---|---|---|---|
| String | String | String | Float64 | |
| 1 | waikato | auckland | milk | 10.0 |
| 2 | waikato | wellington | milk | 2.0 |
| 3 | tauranga | auckland | milk | 2.0 |
| 4 | tauranga | waikato | milk | 2.0 |
| 5 | christchurch | auckland | milk | 4.0 |
| 6 | auckland | christchurch | kiwifruit | 4.0 |
| 7 | waikato | auckland | kiwifruit | 20.0 |
| 8 | waikato | wellington | kiwifruit | 2.0 |
| 9 | tauranga | waikato | kiwifruit | 22.0 |