グラフ分割問題に対する定式化とメタヒューリスティクス

準備

ここでは,付録2で準備したグラフに関する基本操作を集めたモジュール graphtools.py を読み込んでいる. 環境によって,モジュールファイルの場所は適宜変更されたい.

グラフ2分割問題

グラフ2分割問題(graph bipartition problem)は、以下のように定義される。

点数 $n=|V|$ が偶数である無向グラフ $G=(V,E)$ が与えられたとき, 点集合 $V$ の等分割(uniform partition, eqipartition) $(L,R)$ とは, $L \cap R = \emptyset$, $L \cup R =V$, $|L|=|R|=n/2$ を満たす点の部分集合の対である.

$L$ と $R$ をまたいでいる枝 (正確には $i \in L, j \in R$ もしくは $i \in R, j \in L$ を満たす枝 $(i,j)$) の本数を最小にする等分割 $(L,R)$ を求める問題がグラフ2分割問題である.

一般には $k (>2)$ 分割も考えられるが,ここでは $k=2$ に絞って議論する.

問題を明確化するために,グラフ分割問題を整数最適化問題として定式化しておく. 無向グラフ $G=(V,E)$ に対して,$L \cap R =\emptyset$(共通部分がない), $L \cup R =V$(合わせると点集合全体になる)を満たす非順序対 $(L,R)$ を2分割(bipartition)と呼ぶ. 分割 $(L,R)$ において,$L$ は左側,$R$ は右側を表すが,これらは逆にしても同じ分割であるので,非順序対と呼ばれる.

点 $i$ が,分割 $(L,R)$ の $L$ 側に含まれているとき $1$, それ以外の($R$ 側に含まれている)とき $0$ の $0$-$1$ 変数 $x_i$ を導入する. このとき,等分割であるためには,$x_i$ の合計が $n/2$ である必要がある. 枝 $(i,j)$ が $L$ と $R$ をまたいでいるときには,$x_i (1-x_j)$ もしくは $(1-x_i)x_j$ が $1$ になることから,以下の定式化を得る.

$$ \begin{array}{l l l} minimize & \sum_{(i,j) \in E} x_i (1-x_j)+(1-x_i)x_j & \\ s.t. & \sum_{i \in V} x_i = n/2 & \\ & x_i \in \{0,1\} & \forall i \in V \end{array} $$

数理最適化ソルバーの多くは,上のように凸でない2次の項を目的関数に含んだ最小化問題には対応していない. したがって,一般的な(混合)整数最適化ソルバーで求解するためには,2次の項を線形関数に変形してから解く必要がある.

枝 $(i,j)$ が $L$ と $R$ をまたいでいるとき $1$,それ以外のとき $0$ になる $0$-$1$ 変数 $y_{ij}$ を導入する. すると,上の2次整数最適化問題は,以下の線形整数最適化に帰着される.

$$ \begin{array}{l l l} minimize & \sum_{(i,j) \in E} y_{ij} & \\ s.t. & \sum_{i \in V} x_i = n/2 & \\ & x_i -x_j \leq y_{ij} & \forall (i,j) \in E \\ & x_j -x_i \leq y_{ij} & \forall (i,j) \in E \\ & x_i \in \{0,1\} & \forall i \in V \\ & y_{ij} \in \{0,1\} & \forall (i,j) \in E \end{array} $$

最初の制約は等分割を規定する. 2番目の制約は,$i \in L$ で $j \not\in L$ のとき $y_{ij}=1$ になることを規定する. 3番目の制約は,$j \in L$ で $i \not\in L$ のとき $y_{ij}=1$ になることを規定する.

上の定式化を行う関数を以下に示す.

Pythonによる求解

make_data[source]

make_data(n, prob)

make_data: prepare data for a random graph Parameters:

- n: number of vertices
- prob: probability of existence of an edge, for each pair of vertices

Returns a tuple with a list of vertices and a list edges.

gpp[source]

gpp(V, E)

gpp -- model for the graph partitioning problem Parameters:

- V: set/list of nodes in the graph
- E: set/list of edges in the graph

Returns a model, ready to be solved.

V, E = make_data(10, 0.4)
model = gpp(V, E)
model.optimize()
print("Opt.value=", model.ObjVal)
x, y = model.__data
L = [i for i in V if x[i].X >= 0.5]
R = [i for i in V if x[i].X < 0.5]
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 55 rows, 37 columns and 172 nonzeros
Model fingerprint: 0x44488fa2
Variable types: 0 continuous, 37 integer (37 binary)
Coefficient statistics:
  Matrix range     [1e+00, 1e+00]
  Objective range  [1e+00, 1e+00]
  Bounds range     [1e+00, 1e+00]
  RHS range        [5e+00, 5e+00]
Found heuristic solution: objective 13.0000000
Presolve time: 0.00s
Presolved: 55 rows, 37 columns, 172 nonzeros
Variable types: 0 continuous, 37 integer (37 binary)

Root relaxation: objective 0.000000e+00, 16 iterations, 0.00 seconds

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

     0     0    0.00000    0   10   13.00000    0.00000   100%     -    0s
H    0     0                      12.0000000    0.00000   100%     -    0s
H    0     0                       9.0000000    4.66667  48.1%     -    0s
     0     0    8.00000    0   31    9.00000    8.00000  11.1%     -    0s

Cutting planes:
  Gomory: 5
  MIR: 2
  StrongCG: 1
  Zero half: 2
  Mod-K: 2
  RLT: 19

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

Solution count 3: 9 12 13 

Optimal solution found (tolerance 1.00e-04)
Best objective 9.000000000000e+00, best bound 9.000000000000e+00, gap 0.0000%
Opt.value= 9.0
G = nx.Graph()
G.add_edges_from(E)
pos = nx.layout.spring_layout(G)
nx.draw(G, pos=pos, with_labels=True, node_size=500, node_color="yellow", nodelist=L)
edgelist = [(i, j) for (i, j) in E if (i in L and j in R) or (i in R and j in L)]
nx.draw(
    G,
    pos=pos,
    with_labels=False,
    node_size=500,
    nodelist=R,
    node_color="Orange",
    edgelist=edgelist,
    edge_color="red",
)

AMPLによる求解

AMPLモデル(gpp.mod)

set V;
set E within {V,V};
param n := card(V);

var x {V} binary;
var y {E} binary;

minimize z: sum {(i,j) in E} y[i,j];

subject to
Equipart: sum {i in V} x[i] = n/2;
AcrossLR {(i,j) in E}: x[i] - x[j] <= y[i,j];
AcrossRL {(i,j) in E}: x[j] - x[i] <= y[i,j];
# ampl.option['solver'] = 'highs'
# ampl.read("../ampl/gpp.mod")
# ampl.set['V'] = list(V)
# ampl.set['E'] = list(E)

# ampl.solve()
# z = ampl.obj['z']
# print("Optimum:", z.value())

# x = ampl.var['x']
# L = [i for i in V if x[i].value() >.5]
# R = [i for i in V if x[i].value() <.5]
# print("L =", L)
# print("R =", R)

# y = ampl.var['y']
# across = [(i,j) for (i,j) in E if y[i,j].value() > .5]
# print("Across edges:", across)

タブーサーチ

タブーサーチ(tabu search)は,Gloverによって提案されたメタヒューリスティクスであり, グラフ分割問題に対しては,点をLからRに,RからLに移す簡単な近傍をもとに設計できる. 一度移動させた点は,しばらく移動させないことによって,同じ解に戻らないようにすることが,タブー(禁断)の名前の由来である.

以下のメタヒューリスティクスの詳細については,拙著「メタヒューリスティクスの数理」(共立出版)を参照されたい.

construct[source]

construct(nodes)

A simple construction method.

The solution is represented by a vector:

  • sol[i]=0 - node i is in partition 0
  • sol[i]=1 - node i is in partition 1

evaluate[source]

evaluate(nodes, adj, sol, alpha=0.0)

Evaluate a solution.

Input:

  • nodes, adj - the instance's graph
  • sol - the solution to evaluate
  • alpha - a penalty for imbalanced solutions

Determines:

  • the cost of a solution, i.e., the number of edges going from one partition to the other;
  • bal - balance, i.e., the number of vertices in excess in partition 0
  • s[i] - number of edges adjacent to i in the same partition;
  • d[i] - number of edges adjacent to i in a different partition.

find_move_rnd[source]

find_move_rnd(part, nodes, adj, sol, s, d, tabu, tabulen, iteration)

Probabilistically find the best non-tabu move into partition type 'part'.

find_move[source]

find_move(part, nodes, adj, sol, s, d, tabu, tabulen, iteration)

Find the best non-tabu move into partition type 'part'.

move[source]

move(part, nodes, adj, sol, s, d, tabu, tabulen, iteration)

Determine and execute the best non-tabu move.

tabu_search(nodes, adj, sol, max_iter, tabulen, report=None)

Execute a tabu search run.

rndseed = 2
random.seed(rndseed)
n = 100
pos = {i: (random.random(), random.random()) for i in range(n)}
G = nx.random_geometric_graph(n, 0.2, pos=pos)

nodes = G.nodes()
adj = [set([]) for i in nodes]
for (i, j) in G.edges():
    adj[i].add(j)
    adj[j].add(i)

max_iterations = 1000
tabulen = len(nodes) / 2

sol = construct(nodes)
z, _, s, d = evaluate(nodes, adj, sol)
print("initial partition: z =", z)
print(sol)
print()
print("starting tabu search,", max_iterations, "iterations, tabu length =", tabulen)
sol, cost = tabu_search(nodes, adj, sol, max_iterations, tabulen)
print("final solution: z=", cost)
print(sol)
initial partition: z = 239.0
[0, 1, 0, 0, 1, 0, 1, 0, 0, 0, 1, 1, 0, 0, 0, 1, 0, 1, 1, 1, 1, 0, 0, 1, 0, 0, 0, 1, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 0, 1, 0, 1, 1, 1, 1, 0, 0, 0, 1, 0, 1, 0, 1, 1, 0, 0, 0, 1, 0, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1]

starting tabu search, 1000 iterations, tabu length = 50.0
final solution: z= 31.0
[1, 0, 1, 0, 1, 0, 1, 1, 1, 0, 0, 0, 1, 1, 0, 0, 1, 0, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 0, 1, 0, 0, 0, 0, 0, 1, 1, 1, 0, 1, 0, 1, 0, 0, 0, 1, 0, 0, 1, 1, 0, 1, 1, 1, 0, 0, 1, 0, 0, 0, 1, 0, 0, 1, 1, 1, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 1, 1, 1, 0, 1, 1, 0, 1, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 0, 1, 0, 0, 1]
L = [i for i in nodes if sol[i] == 1]
R = [i for i in nodes if sol[i] == 0]
edgelist = [
    (i, j) for (i, j) in G.edges() if (i in L and j in R) or (i in R and j in L)
]
nx.draw(G, pos=pos, with_labels=False, node_size=100, node_color="yellow", nodelist=L)
nx.draw(
    G,
    pos=pos,
    with_labels=False,
    node_size=100,
    nodelist=R,
    node_color="Orange",
    edgelist=edgelist,
    edge_color="red",
)

アニーリング法

ランダム性を用いて局所的最適解からの脱出を試みるメタヒューリスティクスの代表例として アニーリング法(simulated annealing method)がある. この解法の土台は,1953年に Metropolis--Rosenbluth--Rosenbluth--Teller--Tellerによって築かれたものであり,何度も再発見されている. そのため,様ざなま別名をもつ.一部を紹介すると, モンテカルロ焼なまし法(Monte Carlo annealing), 確率的丘登り法(probabilistic hill climbing), 統計的冷却法(statistical cooling), 確率的緩和法(stochastic relaxation), Metropolisアルゴリズム(Metropolis algorithm) などがある.

アニーリング法の特徴は,温度とよばれるパラメータを徐々に小さくしていくことによって,改悪の確率を $0$ に近づけていくことである.. 以下では,分割の非均衡度 $|L|-|R|$ をペナルティとしたアニーリング法を示す.

find_move_rnd_sa[source]

find_move_rnd_sa(n, sol, alpha, s, d, bal)

Find a random node to move from one part into the other. For simulated annealing.

update_move[source]

update_move(adj, sol, s, d, bal, istar)

Execute the chosen move.

metropolis[source]

metropolis(T, delta)

Metropolis criterion for new configuration acceptance

estimate_temperature[source]

estimate_temperature(n, sol, s, d, bal, X0, alpha)

Estimate initial temperature: check empirically based on a series of 'ntrials', that the estimated temperature leads to a rate 'X0'% acceptance:

annealing[source]

annealing(nodes, adj, sol, initprob, L, tempfactor, freezelim, minpercent, alpha, report)

Simulated annealing for the graph partitioning problem

Parameters:

  • nodes, adj - graph definition
  • sol - initial solution
  • initprob - initial acceptance rate
  • L - number of tentatives at each temperature
  • tempfactor - cooling ratio
  • freezelim - max number of iterations with less that minpercent acceptances
  • minpercent - percentage of accepted moves for being not frozen
  • report - function used for output of best found solutions
initprob = 0.4  # initial acceptance probability
sizefactor = 16  # for det. # tentatives at each temp.
tempfactor = 0.95  # cooling ratio
freezelim = 5  # max number of iterations with less that minpercent acceptances
minpercent = 0.02  # fraction of accepted moves for being not frozen
alpha = 3.0  # imballance factor
N = len(nodes)  # neighborhood size
L = N * sizefactor  # number of tentatives at current temperature

print("starting simulated annealing, parameters:")
print("   initial acceptance probability", initprob)
print("   cooling ratio", tempfactor)
print("   # tentatives at each temp.", L)
print("   percentage of accepted moves for being not frozen", minpercent)
print("   max # of it.with less that minpercent acceptances", freezelim)
print("   imballance factor", alpha)
print()
sol = construct(nodes)  # starting solution
z, bal, s, d = evaluate(nodes, adj, sol, alpha)
print("initial partition: z =", z)
print(sol)
print()

LOG = False
sol, z = annealing(
    nodes, adj, sol, initprob, L, tempfactor, freezelim, minpercent, alpha, report=False
)
zp, bal, sp, dp = evaluate(nodes, adj, sol, alpha)
assert abs(zp - z) < 1.0e-9  # floating point approx.equality

print()
print("final solution: z=", z)
print(sol)
starting simulated annealing, parameters:
   initial acceptance probability 0.4
   cooling ratio 0.95
   # tentatives at each temp. 1600
   percentage of accepted moves for being not frozen 0.02
   max # of it.with less that minpercent acceptances 5
   imballance factor 3.0

initial partition: z = 226.0
[0, 1, 0, 1, 1, 1, 0, 0, 1, 1, 1, 0, 0, 1, 1, 1, 1, 0, 0, 1, 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 0, 0, 1, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 0, 1, 0, 0, 1, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 0, 1, 0, 0, 1, 0]


final solution: z= 33.0
[1, 1, 1, 0, 0, 0, 0, 1, 0, 0, 1, 0, 1, 0, 0, 0, 1, 0, 1, 1, 1, 1, 0, 1, 0, 1, 1, 1, 0, 1, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0, 0, 0, 1, 1, 0, 1, 0, 1, 1, 1, 1, 1, 0, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 0, 0, 0, 1, 0, 0, 1, 0, 0, 1, 1, 0, 0, 1, 1, 0, 0, 0, 1, 0, 1]
L = [i for i in nodes if sol[i] == 1]
R = [i for i in nodes if sol[i] == 0]
edgelist = [
    (i, j) for (i, j) in G.edges() if (i in L and j in R) or (i in R and j in L)
]
nx.draw(G, pos=pos, with_labels=False, node_size=100, node_color="yellow", nodelist=L)
nx.draw(
    G,
    pos=pos,
    with_labels=False,
    node_size=100,
    nodelist=R,
    node_color="Orange",
    edgelist=edgelist,
    edge_color="red",
)

集中化と多様化を入れたタブーサーチ

良いメタヒューリスティクスを設計するためのコツは,集中化と多様化の両者をバランス良く組み込むことである. ここで集中化(intensification)とは,良い解のまわりを集中して探索することであり, 多様化(diversification)とは,まだ探していない解を探索することである.

集中化と多様化を加味したタブーサーチを以下に示す.

diversify[source]

diversify(sol, nodes)

Diversify: keep a part of the solution with random size.

The solution is represented by a vector:

  • sol[i]=0 - node i is in partition 0
  • sol[i]=1 - node i is in partition 1

ts_intens_divers[source]

ts_intens_divers(nodes, adj, sol, max_iter, tabulen, report)

Execute a tabu search run, with intensification/diversification.

sol = construct(nodes)
print("initial partition: z =", z)
print(sol)
print()
print("starting tabu search,", max_iterations, "iterations, tabu length =", tabulen)
sol, cost = ts_intens_divers(nodes, adj, sol, max_iterations, tabulen, report=False)
print("final solution: z=", cost)
print(sol)
#     G.nodes[v]["color"] = sol[v]
# fig = gts.to_plotly_fig(G,node_size=30,
#                     line_width=0.1,
#                     line_color='blue',
#                     text_size=10, pos=pos)
# plotly.offline.plot(fig);
L = [i for i in nodes if sol[i] == 1]
R = [i for i in nodes if sol[i] == 0]
edgelist = [
    (i, j) for (i, j) in G.edges() if (i in L and j in R) or (i in R and j in L)
]
nx.draw(G, pos=pos, with_labels=False, node_size=100, node_color="yellow", nodelist=L)
nx.draw(
    G,
    pos=pos,
    with_labels=False,
    node_size=100,
    nodelist=R,
    node_color="Orange",
    edgelist=edgelist,
    edge_color="red",
)

グラフ多分割問題

上では,グラフの2分割問題を考えたが,3以上の複数の集合に,できるだけ均等に分割することを考える. この問題は,グラフ多分割問題(multiway graph partitioning problem)とよばれ,様々な制約が付加された問題が考えられている.

この問題に対するメタヒューリスティクスが,以下のプロジェクトで管理されている.

このパッケージを使うことによって,様々な付加制約がついたグラフ多分割問題を,高速に解くことができる.

METISで最適化する関数 metis

引数

  • $G$: networkXの無向グラフのインスタンス
  • npartition: 分割数を表す2以上の整数値(既定値は2)
  • ufactor: 分割に含まれる点数の非均衡度の上限を与えるパラメータ(既定値は30であり,30\%の非均衡を上限とする.)

metis[source]

metis(G:Graph, npartition:int=2, ufactor:int=30)

metis関数の使用例

rndseed = 2
random.seed(rndseed)
n = 10000
pos = {i: (random.random(), random.random()) for i in range(n)}
G = nx.random_geometric_graph(n, 0.05, pos=pos)
#枝に重みを付与
for u, v in G.edges():
    G[u][v]["weight"] = random.randint(1,1)
#点に重みを付与
for u in G.nodes():
    G.nodes[u]["weight"] = random.randint(1,100)
nx.draw(G, pos=pos, node_size=0.3)
npartition = 5 #分割数
ufactor = 30 #不均衡パラメータ

sol = metis(G, npartition, ufactor)

num_nodes = np.zeros(npartition, int)
for i in sol:
    num_nodes[sol[i]] += G.nodes[i]["weight"]

color_list = [sol[i] for i in G.nodes()]
nx.draw_networkx_nodes(G, pos, node_color=color_list, node_size=5)

# 枝の描画
edge_colors = []
edge_widths = []
obj = 0
for u, v in G.edges():
    if sol[u] != sol[v]:  # 両端ノードの色が異なる場合
        obj += G[u][v]["weight"]
        edge_colors.append("r")  # 赤色で描画
        edge_widths.append(1)  # 太さを2に設定
    else:
        edge_colors.append("k")  # 黒色で描画
        edge_widths.append(0.1)  # 太さを1に設定

nx.draw_networkx_edges(G, pos, edge_color=edge_colors, width=edge_widths)
print(obj, num_nodes)
16899 [103106  99953 101341  98676 102434]

最大カット問題

最大カット問題は、以下のように定義される。

無向グラフ $G=(V,E)$ が与えられたとき, 点集合 $V$ の部分集合 $S$ で, 両端点が $S $ と $V \setminus S$ に含まれる枝集合(カット)の位数が最大のものをもとめよ.

もしくは,枝 $(i,j) \in E$ の上に重み $w_{ij}$ が定義されていて,カットに含まれる枝の重みの合計を最大化する.

メタヒューリスティクスは,グラフ分割問題と同様に設計できる.

線形定式化

グラフ分割問題と同様に, 点 $i$ が,分割 $(L,R)$ の $L$ 側に含まれているとき $1$, それ以外の($R$ 側に含まれている)とき $0$ の $0$-$1$ 変数 $x_i$ を導入する. 枝 $(i,j)$ が $L$ と $R$ をまたいでいるとき $1$,それ以外のとき $0$ になる $0$-$1$ 変数 $y_{ij}$ を導入する.

2分割を表す制約のかわりに, 点 $i,j$ が同じ側に含まれているときに $y_{ij}$ を $0$ にするための制約を追加する.

$$ \begin{array}{l l l} maximize & \sum_{(i,j) \in E} w_{ij} y_{ij} & \\ s.t. & x_i + x_j \geq y_{ij} & \forall (i,j) \in E \\ & 2- x_i - x_j \geq y_{ij} & \forall (i,j) \in E \\ & x_i -x_j \leq y_{ij} & \forall (i,j) \in E \\ & x_j -x_i \leq y_{ij} & \forall (i,j) \in E \\ & x_i \in \{0,1\} & \forall i \in V \\ & y_{ij} \in \{0,1\} & \forall (i,j) \in E \end{array} $$
def maxcut(V, E):
    """maxcut -- model for the graph maxcut problem
    Parameters:
        - V: set/list of nodes in the graph
        - E: set/list of edges in the graph
    Returns a model, ready to be solved.
    """
    model = Model("maxcut")
    x = {}
    y = {}
    for i in V:
        x[i] = model.addVar(vtype="B", name=f"x({i})")
    for (i, j) in E:
        y[i, j] = model.addVar(vtype="B", name=f"y({i},{j})")
    model.update()

    for (i, j) in E:
        model.addConstr(x[i] + x[j] >= y[i, j], f"Edge({i},{j})")
        model.addConstr(2 - x[j] - x[i] >= y[i, j], f"Edge({j},{i})")
        model.addConstr(x[i] - x[j] <= y[i, j], f"EdgeLB1({i},{j})")
        model.addConstr(x[j] - x[i] <= y[i, j], f"EdgeLB2({j},{i})")

    model.setObjective(quicksum(y[i, j] for (i, j) in E), GRB.MAXIMIZE)

    model.update()
    model.__data = x
    return model

2次錐最適化による定式化

ここでは,以下の論文による2次錐最適化による強い定式化を示す.

A NEW SECOND-ORDER CONE PROGRAMMING RELAXATION FOR MAX-CUT PROBLEMS, Masakazu Muramatsu Tsunehiro Suzuki, Journal of the Operations Research, 2003, Vol. 46, No. 2, 164-177

$x_j$ は上の定式化と同じであるが,以下の2つの $0$-$1$ 変数を導入する.

  • $s_{ij}$: $i$ と $j$ が同じ分割に含まれるとき $1$
  • $z_{ij}$: $i$ と $j$ が異なる分割に含まれるとき $1$
$$ \begin{array}{l l l} maximize & \sum_{(i,j) \in E} w_{ij} z_{ij} & \\ s.t. & (x_i + x_j-1)^2 \leq s_{ij} & \forall (i,j) \in E \\ & (x_j- x_i)^2 \leq z_{ij} & \forall (i,j) \in E \\ & s_{ij} + z_{ij} = 1 & \forall (i,j) \in E \\ & x_i \in \{0,1\} & \forall i \in V \\ & s_{ij} \in \{0,1\} & \forall (i,j) \in E \\ & z_{ij} \in \{0,1\} & \forall (i,j) \in E \end{array} $$
def maxcut_soco(V, E):
    """maxcut_soco -- model for the graph maxcut problem
    Parameters:
        - V: set/list of nodes in the graph
        - E: set/list of edges in the graph
    Returns a model, ready to be solved.
    """
    model = Model("max cut -- scop")
    x, s, z = {}, {}, {}
    for i in V:
        x[i] = model.addVar(vtype="B", name=f"x({i})")
    for (i, j) in E:
        s[i, j] = model.addVar(vtype="C", name=f"s({i},{j})")
        z[i, j] = model.addVar(vtype="C", name=f"z({i},{j})")
    #model.update()

    for (i, j) in E:
        model.addConstr((x[i] + x[j] - 1) * (x[i] + x[j] - 1) <= s[i, j], f"S({i},{j})")
        model.addConstr((x[j] - x[i]) * (x[j] - x[i]) <= z[i, j], f"Z({i},{j})")
        model.addConstr(s[i, j] + z[i, j] == 1, f"P({i},{j})")

    model.setObjective(quicksum(z[i, j] for (i, j) in E), GRB.MAXIMIZE)

    #model.update()
    #model.__data = x
    return model

def make_data(n, prob):
    """make_data: prepare data for a random graph
    Parameters:
        - n: number of vertices
        - prob: probability of existence of an edge, for each pair of vertices
    Returns a tuple with a list of vertices and a list edges.
    """
    V = range(1, n + 1)
    E = [(i, j) for i in V for j in V if i < j and random.random() < prob]
    return V, E
#GRB = MDO
from gurobipy import Model, quicksum, GRB

random.seed(3)
V, E = make_data(100, 0.1)

model = maxcut(V, E)
model.optimize()
status = model.Status
if status == GRB.Status.OPTIMAL:
    print("Opt.value=", model.ObjVal)
    obj_lin = model.ObjVal
    x = model.__data
    print([i for i in V if x[i].X >= 0.5])
    print([i for i in V if x[i].X < 0.5])
else:
    pass
#GRB = MDO
from gurobipy import Model, quicksum, GRB

model = maxcut_soco(V, E)
model.optimize()
status = model.Status
if status == GRB.Status.OPTIMAL:
    print("Opt.value=", model.ObjVal)
    x = model.__data
    print([i for i in V if x[i].X >= 0.5])
    print([i for i in V if x[i].X < 0.5])
    # assert model.ObjVal == obj_lin
else:
    pass

2次無制約2値最適化による定式化

Gurobiの最近のバージョンでは,2次無制約2値最適化 (Quadratic unconstrained binary optimization: QUBO)に対する専用のヒューリスティクスを組み込んでいる.

最大カット問題は,QUBOとして以下のように定式化できる. QUBOでは,2値変数は $-1$ か $1$ の値ととるものと仮定する. 分割 $(L,R)$ の $L$ 側に含まれているとき $1$, それ以外の($R$ 側に含まれている)とき $-1$ の2値変数 $x_i$ を用いる.

$$ \begin{array}{l l l} maximize & \sum_{(i,j) \in E} w_{ij} (1-x_{i}x_{j}) & \\ & x_i \in \{-1,1\} & \forall i \in V \end{array} $$

これを,以下の変換を用いて $0$-$1$ 変数 $\hat{x}_i$ に変換する.

$$ \hat{x}_i = \frac{x_i+1}{2} $$

これをQUBOによる代入すると,2次無制約の$0$-$1$ 整数最適化は,以下のようになる.

$$ \begin{array}{l l l} maximize & \sum_{(i,j) \in E} \frac{1}{2} w_{ij} (-4 \hat{x}_{i} \hat{x}_{j}+ 2\hat{x}_{i} + 2 \hat{x}_{j}) & \\ & \hat{x}_i \in \{0,1\} & \forall i \in V \end{array} $$

以下のサイトからベンチマーク問題例を入手できるので, ベンチマーク問題例で実験する.

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

from gurobipy import Model, quicksum, GRB

folder = "../data/maxcut/"
gurobi = []
for iter in range(1,2): #ここで問題番号を指定する(全部で54問)
    f = open(folder + f"g{iter}.rud")
    data = f.readlines()
    f.close()

    n, m = list(map(int, data[0].split()))
    G = nx.Graph()
    edges = []
    for row in data[1:]:
        i, j, w = list(map(int, row.split()))
        edges.append((i, j, w))
    G.add_weighted_edges_from(edges)

    model = Model()

    x = {}
    for i in G.nodes():
        x[i] = model.addVar(name=f"x[{i}]",vtype="B")
    model.setObjective( quicksum(1/2* G[i][j]["weight"]*(-4*x[i]*x[j]+2*x[i]+2*x[j])  for (i, j) in G.edges()), GRB.MAXIMIZE)
    model.Params.TimeLimit = 10
    model.Params.OutputFlag=1
    model.optimize()
    print(iter, model.ObjVal)
    gurobi.append(model.ObjVal)

制約最適化ソルバー SCOP による求解

上と同じ問題例に対して,制約最適化ソルバー SCOP (付録1参照)で求解を試みる.

変数は $X_i (i \in V)$ で値集合は $\{0,1\}$ とする. 値変数は $x_{i0}, x_{i1}$ となる.

ベンチマーク問題例には,枝の重み $w_{ij}, (i,j) \in E$が $1$ のものと $-1$ のものがある.

枝の重みが $1$ の枝に対しては, 重み(逸脱ペナルティ)が $1$ の考慮制約として,以下の2次制約を定義する.

$$ x_{i0} x_{j1} + x_{i1} x_{j0} \geq 1 \ \ \ \forall (i,j) \in E, w_{ij}=1 $$

枝 $(i,j)$ の両端点が異なる集合に含まれるとき左辺は $1$ となり,制約は満たされる. それ以外のとき左辺は $0$ となり,制約は $1$ だけ破られる.

枝の重みが $-1$ の枝に対しては, 重み(逸脱ペナルティ)が $1$ の考慮制約として,以下の2次制約を定義する.

$$ x_{i0} x_{j1} + x_{i1} x_{j0} \leq 0 \ \ \ \forall (i,j) \in E, w_{ij}=-1 $$

枝 $(i,j)$ の両端点が異なる集合に含まれるとき左辺は $1$ となり,制約は $1$ だけ破られる.

枝の本数 $|E|$ から逸脱の総数を減じたものが,目的関数になる.

SCOPモジュールからインポートする.

from scop import *
folder = "../data/maxcut/"
for iter in range(1, 2): #ここで問題番号を指定する(全部で54問)
    f = open(folder + f"g{iter}.rud")
    data = f.readlines()
    f.close()

    n, m = list(map(int, data[0].split()))
    G = nx.Graph()
    edges = []
    for row in data[1:]:
        i, j, w = list(map(int, row.split()))
        edges.append((i, j, w))
    G.add_weighted_edges_from(edges)

    model = Model()

    x = {}
    for i in G.nodes():
        x[i] = model.addVariable(name=f"x[{i}]", domain=[0, 1])
    for (i, j) in G.edges():
        if G[i][j]["weight"] == 1:
            q = Quadratic(name=f"edge_{i}_{j}", weight=1, rhs=1, direction=">=")
            q.addTerms(
                coeffs=[1, 1],
                vars=[x[i], x[i]],
                values=[0, 1],
                vars2=[x[j], x[j]],
                values2=[1, 0],
            )
            model.addConstraint(q)
        elif G[i][j]["weight"] == -1:
            q = Quadratic(name=f"edge_{i}_{j}", weight=1, rhs=0, direction="<=")
            q.addTerms(
                coeffs=[1, 1],
                vars=[x[i], x[i]],
                values=[0, 1],
                vars2=[x[j], x[j]],
                values2=[1, 0],
            )
            model.addConstraint(q)
        else:
            print("no such edge! ")
            break

    model.Params.TimeLimit = 100
    model.Params.RandomSeed = 123
    sol, violated = model.optimize()
    val = 0
    for (i, j) in G.edges():
        if x[i].value != x[j].value:
            if G[i][j]["weight"] == 1:
                val += 1
            elif G[i][j]["weight"] == -1:
                val -= 1
    print(iter, n, m, val)

SCOPとGurobi (ver. 10) ともに100秒で打ち切ったときの実験結果を以下に示す.

folder = "../data/maxcut/"
df = pd.read_csv(folder + "maxcut_bestvalues.csv", index_col=0)
df["gap"] = df["Best Known"] / df["scop(100s)"] * 100
df
#df["gurobi"] = gurobi
#df.to_csv(folder + "maxcut_bestvalues.csv")