割当問題とその変形
%reload_ext autoreload
%autoreload 2
%matplotlib inline
%load_ext lab_black

準備

割当問題

割当問題(assignment problem)とは,集合 $V=\{1, \ldots, n\}$ および $n \times n$ 行列 $C=[c_{ij}]$ が与えられたとき,

$$ \sum_{i \in V} c_{i, \pi(i)} $$


を最小にする順列 $\pi: V \rightarrow \{1 \ldots, n\}$ を求める問題である.

わかりやすさのために,仕事を資源に割り当てる問題として解釈しておこう. $n$ 個の仕事があり,それを $n$ 個の資源(作業員と考えても良い)に割り当てることを考える. 作業と資源には相性があり,仕事 $i$ を資源 $j$ に割り当てた際の費用を $c_{ij}$ とする. 1つの作業員は高々1つの仕事しか処理できず,1つの仕事には必ず1つの資源を割り当てるとき,総費用を最小化する問題が割当問題である.

仕事 $i$ を資源 $j$ に割り当てるとき $1$,それ以外のとき $0$ となる $0$-$1$ 変数を使うと,割当問題は以下のように整数最適化問題として定式化できる.

$$ \begin{array}{l l l} minimize & \sum_{i,j \in V} c_{ij} x_{ij} & \\ s.t. & \sum_{j \in V} x_{ij} = 1 & \forall i \in V \\ & \sum_{i \in V} x_{ij} = 1 & \forall j \in V \\ & x_{ij} \in \{0,1\} & \forall i,j \in V \end{array} $$

この問題は,変数を実数に緩和しても,最適解においては整数になることが知られている.これを完全単模性(total modularity)とよぶ. したがって, $x_{ij} \geq 0$ に緩和して,線形最適化ソルバーで求解すれば,最適解を得ることができる. また,問題を変形することによって,最小費用流問題に帰着できるので,networkXを用いても解くことができる.最小費用流問題の解法には,ネットワーク単体法と容量スケーリング法があるが,前者の方が高速である. また,networkXの実装はPythonであるので,Gurobiなどの商用の数理最適化ソルバーを用いた方が高速である.なお,この問題は極度に退化しているので,退化対策が十分になされていない単体法の実装では,時間がかかることがある.

SciPyにHungary法の実装があるので,これを使う方が高速である.

https://docs.scipy.org/doc/scipy-0.18.1/reference/generated/scipy.optimize.linear_sum_assignment.html

networkXの最小費用流を用いても解けるが,network_simplexは費用が浮動小数点数の場合には非常に遅くなるので,数値を整数に丸めてから実行する必要がある. capacity_scalingは,費用が浮動小数点数でも性能に差はないが,network_simplexより遅い.

n = 1000
cost = np.random.randint(100, 1000, size=(n, n))
%%time
row_ind, col_ind = linear_sum_assignment(cost)
print(cost[row_ind, col_ind].sum())
101037
CPU times: user 35.3 ms, sys: 5.34 ms, total: 40.6 ms
Wall time: 38.9 ms
G = nx.DiGraph()
n = len(cost)
for i in range(n):
    G.add_node(i, demand=-1)
    G.add_node(n + i, demand=1)
G.add_weighted_edges_from([(i, n + j, cost[i, j]) for i in range(n) for j in range(n)])
%%time
val, flow = nx.algorithms.flow.network_simplex(G)
print(val)
101037
CPU times: user 22.1 s, sys: 132 ms, total: 22.2 s
Wall time: 22.2 s
%%time
val, flowDict = nx.capacity_scaling(G)
print(val)
101037
CPU times: user 2min 15s, sys: 839 ms, total: 2min 16s
Wall time: 2min 16s
V = list(range(n))
model = Model("ap")
x = {}
for i in V:
    for j in V:
        x[i, j] = model.addVar(vtype="C", name=f"x[{i},{j}]")
model.update()

for j in V:
    model.addConstr(quicksum(x[i, j] for i in V) == 1)
for i in V:
    model.addConstr(quicksum(x[i, j] for j in V) == 1)

model.setObjective(quicksum(cost[i, j] * x[i, j] for i in V for j in V), GRB.MINIMIZE)
%%time
model.optimize()
print(model.ObjVal)
Gurobi Optimizer version 9.1.1 build v9.1.1rc0 (mac64)
Thread count: 8 physical cores, 16 logical processors, using up to 16 threads
Optimize a model with 2000 rows, 1000000 columns and 2000000 nonzeros
Model fingerprint: 0xdb1962b9
Coefficient statistics:
  Matrix range     [1e+00, 1e+00]
  Objective range  [1e+02, 1e+03]
  Bounds range     [0e+00, 0e+00]
  RHS range        [1e+00, 1e+00]

Concurrent LP optimizer: primal simplex, dual simplex, and barrier
Showing barrier log only...

Presolve time: 1.59s

Solved with dual simplex
Solved in 1043 iterations and 1.59 seconds
Optimal objective  1.010000000e+05
101000.0
CPU times: user 4.35 s, sys: 526 ms, total: 4.87 s
Wall time: 1.77 s

ボトルネック割当問題

ボトルネック割当問題 (bottleneck assignment problem) とは,集合 $V=\{1, \cdots, n\}$ および $n \times n$ 行列 $C=[c_{ij}]$ が与えられたとき,

$$ \max_{i \in V} c_{i \pi(i)} $$

を最小にする順列 $\pi: V \rightarrow \{1 \cdots, n\}$ を求める問題である.

仕事を資源に割り当てる例においては,行列 $C$ を処理時間としたとき,最も遅く終了する時間を最小化することに相当する. 仕事 $i$ が資源 $j$ に割り当てられるとき $1$,それ以外のとき $0$ となる $0$-$1$ 変数 $x_{ij}$ を用いると,ボトルネック割当問題は以下のように定式化できる.

$$ \begin{array}{l l l} minimize & z & \\ s.t. & \sum_{i \in V} c_{ij} x_{ij} \leq z & \forall j \in V \\ & \sum_{i \in V} x_{ij} = 1 & \forall j \in V \\ & \sum_{j \in V} x_{ij} = 1 & \forall i \in V \\ & x_{ij} \in \{0,1\} & \forall i,j \in V \end{array} $$
V = list(range(n))
model = Model("bap")
x = {}
for i in V:
    for j in V:
        x[i, j] = model.addVar(vtype="B", name=f"x[{i},{j}]")
z = model.addVar(vtype="C", name="z")
model.update()

for i in V:
    model.addConstr(quicksum(cost[i, j] * x[i, j] for j in V) <= z)

for j in V:
    model.addConstr(quicksum(x[i, j] for i in V) == 1)
for i in V:
    model.addConstr(quicksum(x[i, j] for j in V) == 1)

model.setObjective(z, GRB.MINIMIZE)
%%time
model.optimize() 
print("obj=", z.X)
Gurobi Optimizer version 9.1.1 build v9.1.1rc0 (mac64)
Thread count: 8 physical cores, 16 logical processors, using up to 16 threads
Optimize a model with 3000 rows, 1000001 columns and 3001000 nonzeros
Model fingerprint: 0x88a36d6a
Variable types: 1 continuous, 1000000 integer (1000000 binary)
Coefficient statistics:
  Matrix range     [1e+00, 1e+03]
  Objective range  [1e+00, 1e+00]
  Bounds range     [1e+00, 1e+00]
  RHS range        [1e+00, 1e+00]
Found heuristic solution: objective 998.0000000
Presolve removed 0 rows and 1 columns
Presolve time: 3.85s
Presolved: 3000 rows, 1000000 columns, 2999341 nonzeros
Variable types: 0 continuous, 1000000 integer (999999 binary)

Root simplex log...

Iteration    Objective       Primal Inf.    Dual Inf.      Time
    2578    1.0700005e+02   9.374737e+02   0.000000e+00      5s
    5037    1.0700010e+02   6.798998e+02   0.000000e+00     10s
    5796    1.0700012e+02   5.280580e+02   0.000000e+00     15s
    6382    1.0700012e+02   3.225718e+02   0.000000e+00     21s
    6867    1.0700012e+02   8.094333e+02   0.000000e+00     26s
    7251    1.0700013e+02   1.336076e+02   0.000000e+00     31s
    7601    1.0700013e+02   3.870846e+01   0.000000e+00     35s
    7911    1.0700013e+02   9.686710e-02   0.000000e+00     40s
    7926    1.0700000e+02   0.000000e+00   0.000000e+00     40s

Root relaxation: objective 1.070000e+02, 7926 iterations, 35.99 seconds
Total elapsed time = 119.26s
Total elapsed time = 139.28s

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

     0     0  107.00000    0  667  998.00000  107.00000  89.3%     -  146s

Explored 0 nodes (230415 simplex iterations) in 146.20 seconds
Thread count was 16 (of 16 available processors)

Solution count 1: 998 

Solve interrupted
Best objective 9.980000000000e+02, best bound 1.070000000000e+02, gap 89.2786%
obj= 998.0
CPU times: user 2min 19s, sys: 1.16 s, total: 2min 20s
Wall time: 2min 26s

閾値を用いた解法

閾値 $t$ より大きい費用を除いて,通常の割当問題として求解し,実行可能解が得られれば,費用の最大値が $t$ の割り当てがえられたことになる.

ボトルネック割当問題の下界は,割り当てを表す制約のいずれか一方を緩和することによって得られる. それらのうちの大きいほうが下界になる.

また,通常の割当問題の解に含まれる最大割当費用が上界になる. 閾値 $t$ を下界から順に大きくしていき,実行可能な割り当てが得られたときの $t$ が,最適値になる.

%%time
LB = max(cost.min(axis=1).max(), cost.min(axis=0).max() )
row_ind, col_ind = linear_sum_assignment(cost)
UB =cost[row_ind, col_ind].max()
for t in range(LB, UB):
    c = cost.flatten()
    c = np.where(c > t, np.inf, c)
    c.shape=(n,n)
    try:
        row_ind, col_ind = linear_sum_assignment(c)
        break
    except:
        continue
print(cost[row_ind, col_ind].max())
107
CPU times: user 35.8 ms, sys: 1.78 ms, total: 37.6 ms
Wall time: 36.2 ms

一般化割当問題

$n$ 個の仕事があり,それを $m$ 個の資源(作業員と考えても良い)に割り当てることを考える. 作業と資源には相性があり,仕事 $j$ を資源 $i$ に割り当てた際の費用を $c_{ij}$ とする. 資源 $i$ には使用可能量の上限(容量) $b_i$ が定義されており,仕事 $j$ を資源 $i$ に割り当てるときには,資源 $a_{ij}$ を使用とするものする. 1つの仕事には必ず1つの資源を割り当てるとき,資源に割り当てられた仕事の資源使用量の合計が資源の容量を超えないという条件のもとで,総費用を最小化する問題が一般化割当問題(generalized assignment problem)である.

仕事 $j$ を資源 $i$ に割り当てるとき $1$,それ以外のとき $0$ となる $0$-$1$ 変数 $x_{ij}$ を用いると,一般化割当問題は以下のように定式化できる.

$$ \begin{array}{l l l} minimize & \sum_{i,j} c_{ij} x_{ij} & \\ s.t. & \sum_{j} a_{ij} x_{ij} \leq b_i & \forall i \\ & \sum_{i} x_{ij} = 1 & \forall j \\ & x_{ij} \in \{0,1\} & \forall i,j \end{array} $$

一般化割当問題は,ナップサック問題を含むので,$NP$-困難である.

以下のサイトからダウンロードでいるベンチマーク問題例を用いる.

https://www-or.amp.i.kyoto-u.ac.jp/members/yagiura/gap/

以下に,データ読み込みのコードを示す.

folder = "../data/gap/"
f = open(folder + "a05100")
data = f.readlines()
f.close()
m, n = list(map(int, data[0].split()))
cost, a, b = {}, {}, {}
data_ = []
for row in data[1:]:
    data_.extend(list(map(int, row.split())))
count = 0
for i in range(m):
    for j in range(n):
        cost[i, j] = data_[count]
        count += 1
for i in range(m):
    for j in range(n):
        a[i, j] = data_[count]
        count += 1
for i in range(m):
    b[i] = data_[count]
    count += 1

メタヒューリスティクス

以下の論文に掲載されているメタヒューリスティクスをPythonから呼び出して使用する.

   M. Yagiura, T. Ibaraki and F. Glover, An Ejection Chain Approach for
   the Generalized Assignment Problem, INFORMS Journal on Computing, 16
   (2004) 133-151.
   M. Yagiura, T. Ibaraki and F. Glover, A Path Relinking Approach with
   Ejection Chains for the Generalized Assignment Problem, European
   Journal of Operational Research, 169 (2006) 548-569.

このコード自体はオープンソースではないが,研究用なら筆者に連絡すれば利用可能である. 詳細は,付録1の OptGAP を参照されたい.

gapmeta[source]

gapmeta(fn, timelimit)

file_name = "a05100"
timelimit = 1
sol = gapmeta(folder + file_name, timelimit)
print("solution=", sol)
solution= [4, 5, 4, 2, 4, 1, 4, 5, 4, 3, 5, 4, 1, 3, 5, 3, 1, 1, 4, 5, 1, 4, 1, 4, 3, 2, 2, 3, 1, 3, 4, 3, 3, 2, 3, 2, 3, 5, 5, 5, 1, 3, 5, 5, 5, 2, 1, 4, 1, 2, 1, 4, 4, 5, 3, 4, 4, 4, 3, 2, 4, 3, 4, 5, 2, 5, 3, 1, 2, 3, 3, 2, 1, 5, 2, 2, 5, 3, 1, 4, 1, 4, 4, 2, 3, 5, 4, 2, 5, 3, 5, 3, 2, 2, 4, 1, 5, 2, 4, 3]
出力されたファイル: # Parameters: timelim = 1 secs., penalty_weight = auto6 (penalub = 65536, penalptb[di] = 0.1 0.01), seed = 13 # Parameters for heuristic swap: maxdhswap = 7, skipassigned = 1, restrict1s = 0, searchbestsw = 1, feasspcsw = 2 # Parameters for ejchain: feasspc = 2, use2ndod = 1, ejchonce = 1, maxdepth = 99, searchbest = 1, bestjobcriterion = 5 (uselagsol=1) # Algorithm type: TABU (return to better solution) # Parameters for subg: sgitrlim = 300, spupdfe = 20 # best time trial 1700 0.00 1 1698 0.00 2 # Total: 312727477 samples and 219724 moves (feas: 24752, inf: 105252) 89721 trials 1 restarts #Average: 3485.55 samples and 2.45 moves (feas: 0.28, inf: 1.17) / trial # moves by: shift = 62269, swap = 29662, ejchain = 38073 # moves by: shift = 0.69, swap = 0.33, ejchain = 0.42 / trial # time by: shift = 0.67, swap = 2.11, ejchain = 3.45 secs. # final penalty: 0.00 0.00 0.00 0.00 0.10 # final avg. weight = 0.0200593 # skips in hswap: max = 24, avr. = 17.2246 # scans of capsortedlist: max = 31, avr. = 24.1516 (by hswap including skips) # scans of capsortedlist: max = 23, avr. = 17.5738 # depth searched: max = 6, avr. = 2.88407 # depth imp found: max = 3, avr. = 1.04589 # depth: 1 2 3 # num. imp found: 36361 1677 35 # subgradient phase: time = 0.00 secs, num = 2 # gain found & total time(secs.) < 5><100> 1698 0.00 10.00 job: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 assigned to: 4 5 4 2 4 1 4 5 4 3 5 4 1 3 5 3 1 1 4 5 1 4 1 4 3 2 2 3 1 3 4 3 3 2 3 2 3 5 5 5 1 3 5 5 5 2 1 4 1 2 1 4 4 5 3 4 4 4 3 2 4 3 4 5 2 5 3 1 2 3 3 2 1 5 2 2 5 3 1 4 1 4 4 2 3 5 4 2 5 3 5 3 2 2 4 1 5 2 4 3 agents: 1 2 3 4 5 rest capacity: 75 36 30 39 3

数理最適化ソルバーによる求解

同じベンチマーク問題例を数理最適化ソルバーで求解する.

I = list(range(m))
J = list(range(n))
model = Model("gap")
x = {}
for i in I:
    for j in J:
        x[i, j] = model.addVar(vtype="B", name=f"x[{i},{j}]")
model.update()

for i in I:
    model.addConstr(quicksum(a[i, j] * x[i, j] for j in J) <= b[i])
for j in J:
    model.addConstr(quicksum(x[i, j] for i in I) == 1)

model.setObjective(quicksum(cost[i, j] * x[i, j] for i in I for j in J), GRB.MINIMIZE)

model.optimize()
Gurobi Optimizer version 9.1.1 build v9.1.1rc0 (mac64)
Thread count: 8 physical cores, 16 logical processors, using up to 16 threads
Optimize a model with 105 rows, 500 columns and 1000 nonzeros
Model fingerprint: 0xdb52b0a9
Variable types: 0 continuous, 500 integer (500 binary)
Coefficient statistics:
  Matrix range     [1e+00, 2e+01]
  Objective range  [1e+01, 5e+01]
  Bounds range     [1e+00, 1e+00]
  RHS range        [1e+00, 3e+02]
Found heuristic solution: objective 3035.0000000
Presolve time: 0.01s
Presolved: 105 rows, 500 columns, 1000 nonzeros
Variable types: 0 continuous, 500 integer (500 binary)

Root relaxation: objective 1.697727e+03, 101 iterations, 0.00 seconds

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

     0     0 1697.72727    0    2 3035.00000 1697.72727  44.1%     -    0s
H    0     0                    1698.0000000 1697.72727  0.02%     -    0s
     0     0 1697.72727    0    2 1698.00000 1697.72727  0.02%     -    0s

Explored 1 nodes (101 simplex iterations) in 0.03 seconds
Thread count was 16 (of 16 available processors)

Solution count 2: 1698 3035 

Optimal solution found (tolerance 1.00e-04)
Best objective 1.698000000000e+03, best bound 1.698000000000e+03, gap 0.0000%
print(model.ObjVal)
1698.0