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

Задача о сетевом потоке нескольких видов товаров

В этом руководстве рассматривается разновидность задачи из раздела The multi-commodity flow problem, в которой граф является не двудольным, а сетевым.

Цель данного руководства — продемонстрировать стиль моделирования с использованием алгебры отношений.

Требуемые пакеты

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

using JuMP
import DataFrames
import HiGHS
import SQLite
import SQLite.DBInterface
import Test

Формулировка

Задача о сетевом потоке нескольких видов товаров является расширением задачи из раздела 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")

16×4 DataFrame

Roworigindestinationproductdistance_km
StringStringStringFloat64
1aucklandwaikatomilk112.0
2aucklandtaurangamilk225.0
3aucklandchristchurchmilk1070.0
4waikatoaucklandmilk112.0
5waikatotaurangamilk107.0
6waikatowellingtonmilk392.0
7taurangaaucklandmilk225.0
8taurangawaikatomilk107.0
9christchurchaucklandmilk1070.0
10aucklandwaikatokiwifruit112.0
11aucklandchristchurchkiwifruit1070.0
12waikatoaucklandkiwifruit112.0
13waikatowellingtonkiwifruit392.0
14taurangaaucklandkiwifruit225.0
15taurangawaikatokiwifruit107.0
16christchurchaucklandkiwifruit1070.0

В таблице products указана стоимость доставки за километр для каждого товара:

df_products = get_table(db, "products")

2×2 DataFrame

Rowproductcost_per_km
StringFloat64
1milk0.001
2kiwifruit0.01

Таблица supply содержит потенциал предложения каждого узла, а также стоимость:

df_supply = get_table(db, "supply")

4×4 DataFrame

Roworiginproductcapacitycost
StringStringFloat64Float64
1waikatomilk10.00.5
2taurangamilk6.01.0
3taurangakiwifruit26.01.0
4christchurchmilk10.00.6

Таблица demand содержит спрос каждого узла:

df_demand = get_table(db, "demand")

8×3 DataFrame

Rowdestinationproductdemand
StringStringFloat64
1aucklandmilk16.0
2aucklandkiwifruit16.0
3taurangamilk2.0
4taurangakiwifruit2.0
5wellingtonmilk2.0
6wellingtonkiwifruit2.0
7christchurchmilk4.0
8christchurchkiwifruit4.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

16×5 DataFrame

Roworigindestinationproductdistance_kmx_flow
StringStringStringFloat64GenericV…​
1aucklandwaikatomilk112.0x
2aucklandtaurangamilk225.0x
3aucklandchristchurchmilk1070.0x
4waikatoaucklandmilk112.0x
5waikatotaurangamilk107.0x
6waikatowellingtonmilk392.0x
7taurangaaucklandmilk225.0x
8taurangawaikatomilk107.0x
9christchurchaucklandmilk1070.0x
10aucklandwaikatokiwifruit112.0x
11aucklandchristchurchkiwifruit1070.0x
12waikatoaucklandkiwifruit112.0x
13waikatowellingtonkiwifruit392.0x
14taurangaaucklandkiwifruit225.0x
15taurangawaikatokiwifruit107.0x
16christchurchaucklandkiwifruit1070.0x

Для узлов предложения мы добавим переменную в каждую строку, а затем установим верхнюю границу равной потенциалу предложения узла:

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

4×5 DataFrame

Roworiginproductcapacitycostx_supply
StringStringFloat64Float64GenericV…​
1waikatomilk10.00.5s
2taurangamilk6.01.0s
3taurangakiwifruit26.01.0s
4christchurchmilk10.00.6s

Наша цель — минимизировать стоимость доставки и стоимость поставки. Чтобы вычислить стоимость потока, необходимо объединить таблицу доставки, которая содержит 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

16×7 DataFrame

Roworigindestinationproductdistance_kmx_flowcost_per_kmflow_cost
StringStringStringFloat64GenericV…​Float64?Float64
1aucklandwaikatomilk112.0x0.0010.112
2aucklandtaurangamilk225.0x0.0010.225
3aucklandchristchurchmilk1070.0x0.0011.07
4waikatoaucklandmilk112.0x0.0010.112
5waikatotaurangamilk107.0x0.0010.107
6waikatowellingtonmilk392.0x0.0010.392
7taurangaaucklandmilk225.0x0.0010.225
8taurangawaikatomilk107.0x0.0010.107
9christchurchaucklandmilk1070.0x0.0011.07
10aucklandwaikatokiwifruit112.0x0.011.12
11aucklandchristchurchkiwifruit1070.0x0.0110.7
12waikatoaucklandkiwifruit112.0x0.011.12
13waikatowellingtonkiwifruit392.0x0.013.92
14taurangaaucklandkiwifruit225.0x0.012.25
15taurangawaikatokiwifruit107.0x0.011.07
16christchurchaucklandkiwifruit1070.0x0.0110.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]))
)

8×3 DataFrame

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]))
)

9×3 DataFrame

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])

10×4 DataFrame

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],
)

10×5 DataFrame

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],
)

10×6 DataFrame

Rownodeproductx_flow_inx_flow_outx_supplydemand
StringStringAffExpr?AffExpr?GenericV…​?Float64?
1taurangamilkx[2] + xx[7] + xs2.0
2christchurchmilkxxs4.0
3taurangakiwifruitmissingx[14] + xs2.0
4aucklandmilkx[4] + x[7] + xx[1] + x[2] + xmissing16.0
5christchurchkiwifruitxxmissing4.0
6aucklandkiwifruitx[12] + x[14] + xx[10] + xmissing16.0
7wellingtonmilkxmissingmissing2.0
8wellingtonkiwifruitxmissingmissing2.0
9waikatomilkx[1] + xx[4] + x[5] + xsmissing
10waikatokiwifruitx[10] + xx[12] + xmissingmissing

Теперь мы готовы добавить ограничение массового баланса. Так как в некоторых строках есть значения 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],
)

9×4 DataFrame

Roworigindestinationproductx_flow
StringStringStringFloat64
1waikatoaucklandmilk10.0
2waikatowellingtonmilk2.0
3taurangaaucklandmilk2.0
4taurangawaikatomilk2.0
5christchurchaucklandmilk4.0
6aucklandchristchurchkiwifruit4.0
7waikatoaucklandkiwifruit20.0
8waikatowellingtonkiwifruit2.0
9taurangawaikatokiwifruit22.0