2次割当問題に対するメタヒューリスティクス

準備

2次割当問題とは

2次割当問題(quadratic assignment problem)とは,集合 $V=\{1, \cdots, n\}$ および2つの $n \times n$ 行列 $F=[f_{ij}]$,$D=[d_{k \ell}]$ が与えられたとき,

$$ \sum_{i \in V} \sum_{j \in V} f_{ij} d_{\pi(i) \pi(j)} $$


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

この問題は,施設の配置を決定する応用から生まれた.$n$ 個の施設があり,それを $n$ 箇所の地点に配置することを 考える.施設 $i,j$ 間には物の移動量 $f_{ij}$ があり, 地点 $k,\ell$ 間を移動するには距離 $d_{k \ell}$ がかかるものとする. 問題の目的は,物の総移動距離を最小にするように, 各地点に1つずつ施設を配置することである. 順列 $\pi$ は施設 $i$ を地点 $\pi(j)$ に配置することを表す.

2次割当問題にはQAPLIBとよばれるベンチマーク問題集がある.以下のサイトから入手できる.

http://anjos.mgi.polymtl.ca/qaplib/inst.html

folder = "../data/qap/"
df = pd.read_csv(folder + "qaplib.csv")
df
file name best value best solution
0 chr12a.dat 9552 7 5 12 2 1 3 9 11 10 6 8 4\n
1 chr12b.dat 9742 5 7 1 10 11 3 4 2 9 6 12 8\n
2 chr12c.dat 11156 7 5 1 3 10 4 8 6 9 11 2 12\n
3 chr15a.dat 9896 5 10 8 13 12 11 14 2 4 6 7 15 3 1 9\n
4 chr15b.dat 7990 4 13 15 1 9 2 5 12 6 14 7 3 10 11 8\n
... ... ... ...
85 tho150.dat 8133398 99 107 68 47 142 138 2 114 150 74\n
86 tho30.dat 149936 8 6 20 17 19 12 29 15 1 2 30 11 13 28 23 27 16 \n
87 tho40.dat 240516 40 2 19 23 24 7 34 3 39 14 20 15 1 10 11 17 ...
88 wil100.dat 273038 15 28 100 64 95 88 32 87 30 50 9...
89 wil50.dat 48816 1 48 40 31 15 33 47 19 9 27 14 5 32 44 3 24 3...

90 rows × 3 columns

定式化

施設 $i$ が地点 $k$ に配置されるとき $1$,それ以外のとき $0$ となる $0$-$1$ 変数 $x_{ik}$ を用いると,2次割当問題は以下のように定式化できる.

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

市販の数理最適化ソルバーは,通常,上のような(下に凸でない)2次の関数を含んだ最小化問題には対応していなかったが,最近ではGurobiがこれに対応した解法を取り入れた. 小規模で疎な行列の問題例なら,比較的短時間で解ける場合もある.

整数線形最適化に帰着させるためには,様々な方法が提案されているが,ここでは2つの定式化を紹介しよう.

はじめの定式化は,施設 $i$ を地点 $k$ に配置したときの,施設 $i$ と他の施設の間の費用の合計を表す実数変数 $w_{ik}$ を追加したものであり, $O(n^2)$ 個の変数を用いたコンパクトなものである.

施設 $i$ を地点 $k$ に配置したときの費用の上界となるパラメータ $M_{ik}$ を導入する. $$ M_{ik} =\sum_{j,\ell \in V} f_{ij} d_{k \ell} $$

線形整数最適化による定式化は以下のように書ける.

$$ \begin{array}{l l l} minimize & \sum_{i,k \in V} w_{ik} & \\ s.t. & \sum_{i \in V} x_{ik} = 1 & \forall k \in V \\ & \sum_{k \in V} x_{ik} = 1 & \forall i \in V \\ & M_{ik}(x_{ik}-1) +\sum_{j,\ell \in V} f_{ij} d_{k \ell} x_{j \ell} \leq w_{ik} & \forall i,k \in V \\ & x_{ik} \in \{0,1\} & \forall i,k \in V \\ & w_{ik} \geq 0 & \forall i,k \in V \end{array} $$

次の定式化は,施設 $i$ が地点 $k$ に配置され,かつ施設 $j$ が地点 $\ell$に配置されるとき $1$,それ以外のとき $0$ となる4添え字の $0$-$1$ 変数 $y_{ikj\ell}$ を用いたものである. 変数の数は $O(n^4)$ 個と多いが,得られる下界は強くなる. 線形整数最適化による定式化は以下のように書ける.

$$ \begin{array}{l l l} minimize & \sum_{i,j \in V, i \neq j} \sum_{k, \ell \in V, k \neq \ell} f_{ij} d_{k \ell} y_{ikj \ell} & \\ s.t. & \sum_{i \in V} x_{ik} = 1 & \forall k \in V \\ & \sum_{k \in V} x_{ik} = 1 & \forall i \in V \\ & \sum_{i \in V, i \neq j} y_{ikj\ell} =x_{j\ell} & \forall k,j,\ell \in V, k \neq \ell \\ & \sum_{k \in V, k \neq \ell} y_{ikj\ell} =x_{j\ell} & \forall i,j,\ell \in V, i \neq j \\ & y_{ikj\ell}=y_{j\ell ik} & \forall i,j,k,\ell \in V, i \neq j, k \neq \ell \\ & x_{ik} \in \{0,1\} & \forall i,k \in V \\ & y_{ikj\ell} \in \{0,1\} & \forall i,k,j,\ell \in V, i \neq j, k \neq \ell \end{array} $$

以下ではベンチマーク問題例を読み込み,$O(n^2)$ 個の変数を用いた定式化を用いて数理最適化ソルバーで求解する. ただし,この問題は難しいので,計算を途中で打ち切り,得られた最良解で評価する.

def read_qap(filename):
    """Read data for a QAP problem from file in QAPLIB format."""
    try:
        if len(filename) > 3 and filename[-3:] == ".gz":  # file compressed with gzip
            import gzip

            f = gzip.open(filename, "rb")
        else:  # usual, uncompressed file
            f = open(filename)
    except IOError:
        print("could not open file", filename)
        return None

    data = f.read()
    f.close()

    try:
        pass
        data = data.split()
        n = int(data.pop(0))
        f = {}  # for n times n flow matrix
        d = {}  # for n times n distance matrix
        for i in range(n):
            for j in range(n):
                f[i, j] = int(data.pop(0))
        for i in range(n):
            for j in range(n):
                d[i, j] = int(data.pop(0))
    except IndexError:
        print("inconsistent data on QAP file", filename)
        exit(-1)
    return n, f, d
n, f, d = read_qap(folder +"tai20a.dat")
V = list(range(n))
model = Model("qap")
x = {}
for i in V:
    for j in V:
        x[i, j] = model.addVar(vtype="B", 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(
        f[i, j] * d[k, ell] * x[i, k] * x[j, ell]
        for i in V
        for j in V
        for k in V
        for ell in V
    ),
    GRB.MINIMIZE,
)
model.Params.NoRelHeurTime = 600.
#model.Params.PreQLinearize = 1
model.optimize()
Set parameter NoRelHeurTime to value 600
Gurobi Optimizer version 9.5.2 build v9.5.2rc0 (mac64[x86])
Thread count: 8 physical cores, 16 logical processors, using up to 16 threads
Optimize a model with 40 rows, 400 columns and 800 nonzeros
Model fingerprint: 0xfba4ddf3
Model has 69938 quadratic objective terms
Variable types: 0 continuous, 400 integer (400 binary)
Coefficient statistics:
  Matrix range     [1e+00, 1e+00]
  Objective range  [0e+00, 0e+00]
  QObjective range [4e+00, 4e+04]
  Bounds range     [1e+00, 1e+00]
  RHS range        [1e+00, 1e+00]
Found heuristic solution: objective 878144.00000

Interrupt request received
Presolve time: 0.12s
Presolved: 40 rows, 400 columns, 800 nonzeros
Presolved model has 70338 quadratic objective terms
Variable types: 0 continuous, 400 integer (400 binary)
Starting NoRel heuristic
Found heuristic solution: objective 856222.00000
Found heuristic solution: objective 837092.00000
Found heuristic solution: objective 814050.00000
Found heuristic solution: objective 810368.00000
Found heuristic solution: objective 794652.00000
Found heuristic solution: objective 786588.00000
Found heuristic solution: objective 774200.00000
Found heuristic solution: objective 767312.00000
Found heuristic solution: objective 762474.00000
Found heuristic solution: objective 759630.00000
Found heuristic solution: objective 757354.00000
Found heuristic solution: objective 748668.00000
Found heuristic solution: objective 746248.00000
Found heuristic solution: objective 743360.00000
Found heuristic solution: objective 743162.00000
Found heuristic solution: objective 737652.00000
Found heuristic solution: objective 735972.00000
Found heuristic solution: objective 735762.00000
Found heuristic solution: objective 722716.00000
Elapsed time for NoRel heuristic: 6s (best bound -9.84547e+07)
Elapsed time for NoRel heuristic: 13s (best bound -9.84547e+07)
Found heuristic solution: objective 721428.00000
Found heuristic solution: objective 719246.00000
Elapsed time for NoRel heuristic: 19s (best bound -9.84547e+07)
Elapsed time for NoRel heuristic: 25s (best bound -9.84547e+07)
Elapsed time for NoRel heuristic: 35s (best bound -9.84547e+07)
Elapsed time for NoRel heuristic: 40s (best bound -9.84547e+07)
Found heuristic solution: objective 710578.00000
Elapsed time for NoRel heuristic: 46s (best bound -9.84547e+07)
Elapsed time for NoRel heuristic: 52s (best bound -9.84547e+07)
n, f, d = read_qap(folder +"tai20a.dat")
V = list(range(n))
M ={}
for i in V:
    for k in V:
        sum_ = 0
        for j in V: 
            for ell in V:
                sum_ += f[i,j]*d[k,ell]
        M[i,k] = sum_
model = Model("qap-linear")

x, w = {}, {}
for i in V:
    for j in V:
        w[i, j] = model.addVar(vtype="C", name=f"w[{i},{j}]")
        x[i, j] = model.addVar(vtype="B", 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)

for i in V:
    for k in V:
        model.addConstr(M[i,k]*(x[i,k]-1) +
                        quicksum(f[i,j]*d[k,ell]*x[j,ell] for j in V for ell in V) <= w[i,k])
    
model.setObjective(
    quicksum(
        w[i, k]
        for i in V
        for k in V
    ),
    GRB.MINIMIZE,
)

model.Params.NoRelHeurTime = 600.
#model.Params.PreQLinearize = 1

model.optimize()
Set parameter Username
Academic license - for non-commercial use only - expires 2024-11-13
Set parameter NoRelHeurTime to value 600
Gurobi Optimizer version 9.5.2 build v9.5.2rc0 (mac64[x86])
Thread count: 8 physical cores, 16 logical processors, using up to 16 threads
Optimize a model with 440 rows, 800 columns and 141476 nonzeros
Model fingerprint: 0x14a3a12c
Variable types: 400 continuous, 400 integer (400 binary)
Coefficient statistics:
  Matrix range     [1e+00, 1e+06]
  Objective range  [1e+00, 1e+00]
  Bounds range     [1e+00, 1e+00]
  RHS range        [1e+00, 1e+06]
Found heuristic solution: objective 878144.00000
Presolve time: 0.22s
Presolved: 440 rows, 800 columns, 141476 nonzeros
Variable types: 0 continuous, 800 integer (400 binary)
Starting NoRel heuristic
Found heuristic solution: objective 820626.00000
Found heuristic solution: objective 789078.00000
Found heuristic solution: objective 778788.00000
Found heuristic solution: objective 776176.00000
Found heuristic solution: objective 775692.00000
Found heuristic solution: objective 773720.00000
Found heuristic solution: objective 766854.00000
Found heuristic solution: objective 758430.00000
Found heuristic solution: objective 757046.00000
Found heuristic solution: objective 754988.00000
Found heuristic solution: objective 747758.00000
Found heuristic solution: objective 747726.00000
Found heuristic solution: objective 746136.00000
Found heuristic solution: objective 744308.00000
Found heuristic solution: objective 739662.00000
Found heuristic solution: objective 739556.00000
Found heuristic solution: objective 728560.00000
Elapsed time for NoRel heuristic: 5s (best bound 0)
Elapsed time for NoRel heuristic: 12s (best bound 0)
Elapsed time for NoRel heuristic: 19s (best bound 0)
Elapsed time for NoRel heuristic: 25s (best bound 0)
Elapsed time for NoRel heuristic: 31s (best bound 0)
Elapsed time for NoRel heuristic: 38s (best bound 0)
Elapsed time for NoRel heuristic: 46s (best bound 0)
Elapsed time for NoRel heuristic: 51s (best bound 0)
Elapsed time for NoRel heuristic: 57s (best bound 0)
Found heuristic solution: objective 726728.00000
Elapsed time for NoRel heuristic: 63s (best bound 0)
Elapsed time for NoRel heuristic: 70s (best bound 0)
NoRel heuristic complete

Explored 0 nodes (0 simplex iterations) in 69.80 seconds (76.58 work units)
Thread count was 16 (of 16 available processors)

Solution count 10: 726728 728560 739556 ... 757046

Solve interrupted
Best objective 7.267280000000e+05, best bound 0.000000000000e+00, gap 100.0000%

タブーサーチ

2次割当問題に対しては,解を表す順列の2つの要素を交換することによって 自然な近傍が定義できる.これは,2つの施設の配置場所を交換することを表す. これを交換近傍とよぶ.

交換近傍と補助行列による目的関数値の差分の高速計算を用いて,タブーサーチを設計する. タブーサーチ対しては,何を禁断リストに入れるのかを決める必要がある. 定式化では,施設 $i$ を地点 $k$ に配置するときに $1$ になる変数 $x_{ik}$ を用いていたことから, 施設 $i$ を地点 $k$ から他の地点に移動したとき,再び地点 $k$ に戻ることを一定の反復の間禁止することにする.

集中化と多様化を入れたタブーサーチで,数理最適化で解けなかった問題例を解いてみる.

mk_rnd_data[source]

mk_rnd_data(n, scale=10)

Make data for a random problem of size 'n'.

evaluate__[source]

evaluate__(n, f, d, pi)

Evaluate solution 'pi' from scratch.

evaluate[source]

evaluate(n, f, d, pi)

Evaluate solution 'pi' and create additional cost information for incremental evaluation.

construct[source]

construct(n, f, d)

Random construction.

diversify[source]

diversify(pi)

remove part of the solution, random re-construct it

find_move[source]

find_move(n, f, d, pi, delta, tabu, iteration)

Find and return best non-tabu move.

tabu_search(n, f, d, max_iter, length, report=None)

Construct a random solution, and do 'max_iter' tabu search iterations on it.

n, f, d = read_qap(folder +"tai60a.dat")
print("starting tabu search")
tabulen = n
max_iterations = 30000
pi, cost = tabu_search(n, f, d, max_iterations, tabulen, report=print)

print("final solution: z =", cost)
print(pi)
starting tabu search
8392516 it:0
8320114 it:1
8253870 it:2
8203634 it:3
8153766 it:4
8107320 it:5
8064410 it:6
8025324 it:7
7980760 it:8
7936506 it:9
7891600 it:10
7866090 it:11
7840892 it:12
7808458 it:13
7787468 it:14
7776624 it:15
7760550 it:16
7744352 it:17
7724122 it:18
7693968 it:19
7677094 it:20
7664922 it:21
7657534 it:22
7652910 it:23
7647622 it:24
7635922 it:25
7634658 it:26
7622794 it:27
7616894 it:28
7606214 it:29
7601556 it:30
7599178 it:31
7595302 it:32
7591864 it:33
7583536 it:34
7574068 it:35
7569362 it:36
7568542 it:37
7561276 it:38
7559960 it:39
7558482 it:40
7548390 it:43
7547776 it:44
7544422 it:45
7540592 it:46
7521496 it:47
7516792 it:48
7513934 it:49
7509114 it:50
7507448 it:52
7505738 it:53
7501876 it:61
7491502 it:66
7490660 it:67
7483772 it:68
7478900 it:69
7466042 it:71
7464698 it:73
7453980 it:74
7445456 it:76
7444004 it:77
7436068 it:80
7431134 it:82
7429170 it:153
7428910 it:154
7421752 it:160
7405658 it:165
7402950 it:167
7401224 it:429
7398400 it:972
7386112 it:1097
7376380 it:1098
7374286 it:1099
7368598 it:1102
7367734 it:1103
7365986 it:9074
7365332 it:12572
7365332 it:29999
final solution: z = 7365332
[36, 58, 25, 41, 9, 31, 35, 43, 2, 55, 26, 54, 37, 15, 10, 6, 34, 51, 49, 21, 16, 17, 52, 28, 12, 13, 53, 0, 4, 32, 22, 5, 7, 23, 14, 45, 50, 38, 44, 39, 29, 56, 1, 24, 19, 18, 48, 20, 59, 27, 30, 8, 47, 46, 33, 40, 42, 57, 3, 11]

SciPyの近似解法

SciPyにも2次割当問題の近似解法が含まれている. 引数 method で解法を選ぶことができる. 引数には "faq" (Fast Approximate QAP Algorithm)と"2opt"が使える. どちらも高速ではあるが,解はあまり良くない.

https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.quadratic_assignment.html

n, f, d = read_qap(folder + "wil50.dat")
n, f, d = read_qap(folder +"tai20a.dat")
F = np.zeros((n, n))
for (i, j) in f:
    F[i, j] = f[i, j]
D = np.zeros((n, n))
for (i, j) in d:
    D[i, j] = d[i, j]
quadratic_assignment(A=F, B=D, method="faq")
 col_ind: array([ 3,  4, 15, 13, 16,  5,  8,  2, 14, 18,  7, 17,  6, 11,  9, 12,  1,
       10,  0, 19])
     fun: 736140.0
     nit: 30
quadratic_assignment(A=F, B=D, method="2opt")
 col_ind: array([ 5,  8,  1, 17, 18, 13, 12, 19, 16,  2, 14,  6, 15,  0, 11, 10,  3,
        4,  9,  7])
     fun: 747392.0
     nit: 921

線形順序付け問題

2次割当問題の特殊形に線形順序付け問題 (linear ordering problem) がある.

線形順序付け問題とは,集合 $V=\{1, \ldots, n\}$ および $n \times n$ 行列 $C=[c_{ij}]$ が与えられたとき,

$$ \sum_{i < j } c_{\pi(i) \pi(j)} $$

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

定式化

$\pi (i) < \pi (j)$ のとき $1$ になる $0$-$1$ 変数 $x_{ij}$ を用いると,線形順序付け問題は以下のように定式化できる.

$$ \begin{array}{l l l} maximize & \sum_{i,j \in V, i \neq j } c_{ij} x_{ij} & \\ s.t. & x_{ij} + x_{ji} = 1 & \forall i,j \in V, i < j \\ & x_{ij} + x_{jk} + x_{ki} \leq 2 & \forall i < j, i < k, j \neq k \\ & x_{ij} \in \{0,1\} & \forall i,j \in V, i \neq j \end{array} $$

線形順序付け問題のベンチマーク問題例は,以下から入手できる.

http://grafo.etsii.urjc.es/optsicom/lolib/

folder = "../data/lop/"
fn = "N-be75np"
f = open(folder + fn)
data = f.readlines()
f.close()
n = int(data[0])
print(n)
c = []
for row in data[1:]:
    c.append(list(map(int, row.split())))
cost = np.array(c)
cost
50
array([[13745,     0,     0, ...,  1596,     0,     0],
       [  167,    50,     0, ...,    23,     0,     0],
       [    0,  8855,     0, ...,     0,     0,     0],
       ...,
       [    0,     0,     0, ...,     0,     0,     0],
       [  206,    40,    12, ...,   716,     0,     0],
       [  331,    95,    30, ...,   274,     0,     0]])
sol = "7 48 49 33 38 50 11 28 25 27 41 45 37 23 22 20 24 26 42 43 1 5 21 39 34 19 15 18 16 17 14 32 8 9 10 35 36 30 46 29 31 13 12 3 44 2 40 4 6 47"
sol_list = list(map(int, sol.split()))
total = 0
for i in range(n - 1):
    for j in range(i + 1, n):
        total += cost[sol_list[i] - 1, sol_list[j] - 1]
total
571469
V = list(range(n))
model = Model("lop")
x = {}
for i in V:
    for j in V:
        if i != j:
            x[i, j] = model.addVar(vtype="B", name=f"x[{i},{j}]")
model.update()

for i in range(n - 1):
    for j in range(i + 1, n):
        model.addConstr(x[i, j] + x[j, i] == 1)
for i in range(n - 1):
    for j in range(i + 1, n):
        for k in range(i + 1, n):
            if j != k:
                model.addConstr(x[i, j] + x[j, k] + x[k, i] <= 2)

model.setObjective(
    quicksum(cost[i, j] * x[i, j] for i in V for j in V if i != j), GRB.MAXIMIZE
)

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 40425 rows, 2450 columns and 120050 nonzeros
Model fingerprint: 0x425bb355
Variable types: 0 continuous, 2450 integer (2450 binary)
Coefficient statistics:
  Matrix range     [1e+00, 1e+00]
  Objective range  [1e+00, 6e+04]
  Bounds range     [1e+00, 1e+00]
  RHS range        [1e+00, 2e+00]
Found heuristic solution: objective 410474.00000
Presolve removed 1225 rows and 1225 columns
Presolve time: 0.18s
Presolved: 39200 rows, 1225 columns, 117600 nonzeros
Variable types: 0 continuous, 1225 integer (1225 binary)

Root relaxation: objective 7.170170e+05, 631 iterations, 0.11 seconds

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

     0     0 717017.000    0  130 410474.000 717017.000  74.7%     -    0s
H    0     0                    715257.00000 717017.000  0.25%     -    0s
H    0     0                    716410.00000 717017.000  0.08%     -    0s
H    0     0                    716992.00000 717017.000  0.00%     -    0s

Cutting planes:
  Gomory: 1
  Zero half: 8

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

Solution count 4: 716992 716410 715257 410474 

Optimal solution found (tolerance 1.00e-04)
Best objective 7.169920000000e+05, best bound 7.169940000000e+05, gap 0.0003%
print(model.ObjVal)
D = nx.DiGraph()
for (i, j) in x:
    if x[i, j].X > 0:
        D.add_edge(i, j)
perm = []
for i in nx.topological_sort(D):
    perm.append(i)
print(perm)
716992.0
[49, 48, 47, 46, 40, 18, 17, 24, 15, 26, 36, 23, 19, 22, 20, 32, 37, 16, 27, 29, 25, 0, 9, 10, 12, 21, 14, 13, 31, 7, 11, 38, 35, 8, 2, 33, 34, 41, 30, 39, 42, 44, 4, 5, 1, 45, 6, 28, 43, 3]