記事「量子アニーリングマシンによる配送計画」で扱った論文では、CVRP(容量制約有りの配送計画問題)を量子アニーリングマシンと古典コンピュータを用いたハイブリッドな手法で解き、その性能を古典コンピュータと比較していました。本記事では、論文中で登場したCVRPLIBのデータセットについてクラスタリングはSA(シミュレーテッドアニーリング)、ルーティングはGoogleのOR-Toolsを利用して解いています。
文献情報
- 1つ目の論文
- タイトル:A Hybrid Solution Method for the Capacitated Vehicle Routing Problem Using a Quantum Annealer
- 著者:Sebastian Feld, Chrisoph Roch, Thomas Gabor, Christian Seidel, Florian Neukart, Isabella Galter, Wolfgang Mauerer, and Claudia Linnhoff-Popien
- 書誌情報:https://doi.org/10.3389/fict.2019.00013
- 2つ目の論文
- タイトル:An Approach to the Vehicle Routing Problem with Balanced Pick-up Using Ising Machines
- 著者:Siya Bao, Masashi Tawada, Shu Tanaka, Nozomu Togawa
- 書誌情報:https://ieeexplore.ieee.org/document/9427355
問題
この実践記事では、以下の2種類の手法でCVRPを解いてみました。
- 元論文に載っていた手法
- 容量の平均値に寄せる手法
クラスタリング
Part1 元論文に載っていた手法
論文紹介の記事では、容量制約付きのクラスタリングに関する定式化を省いていたので、ここで簡単に説明します。
まずは変数についてです。
- $x_{v}^k$: $k$番目のクラスターに$v$番目の顧客が含まれるときは1を、そうでないときは0をとる二値変数
- $y_n^k$: 不等式制約を考えるための擬似変数
最適化に必要な入力データは以下のとおりです。
- $w_{v}$: 顧客の要求量を表す定数
- $D_{uv}$: 顧客どうしの距離を表す行列
- $m$: クラスターの数
- $W$: クラスターの容量の最大値
続いて定式化です。以下の3つの項から構成されています。
- トラックの容量に関する不等式制約$$ H_A = X\sum_{k=1}^m\left(1-\sum_{n=1}^Wy_n^k\right)^2 + A\sum_{k=1}^m\left(\sum_{n=1}^Wny_n^k- \sum_{v=1}^n w_{v} x_{v}^k\right)^2 $$第一項がトラックの容量を一つに定めるone-hot制約、第二項がトラックの容量と荷物の重量の総和を等しくする制約となっています。
- one-hot制約$$ H_B = B\sum_{v=1}^n \left(1- \sum_{k=1}^{m}x_{v}^k\right)^2 $$この式は、顧客が一つのクラスターにだけ含まれるようにする制約となっています。
- 距離に関するコスト関数$$ H_C = C\sum_{k=1}^{m}\left(\sum_{(u,v)\in E}D_{uv}x_u^kx_v^k\right)$$クラスター内の顧客どうしの距離の総和を計算しています。これが最小になるような解を求めることがCVRPのクラスタリング部分の目的です。なお、$X,A,B,C$は制約の重みを決定するための定数です。
!pip install numpy
!pip install matplotlib
!pip install dwave-ocean-sdk
!pip install openjij
!pip install ortools
!pip install vrplib
import itertools
import numpy as np
import matplotlib.pyplot as plt
from pyqubo import Array, Constraint, Placeholder
この記事では、CVRPLIBのCMT1インタスンスを実験の対象とします。
import vrplib
instance_name = "CMT1"
path_instance = "cvrp.vrp"
path_solution = "cvrp.sol"
vrplib.download_instance(instance_name, path_instance)
vrplib.download_solution(instance_name, path_solution)
vrp_instance = vrplib.read_instance(path_instance)
vrp_solution = vrplib.read_solution(path_solution)
customers = vrp_instance["node_coord"]
customers_nodepot = customers[1:] # 0: depot
cus_num = len(customers_nodepot)
# 距離行列の生成
D_uv = np.zeros((cus_num, cus_num))
for u in range(cus_num):
for v in range(u + 1, cus_num):
distance_uv = np.linalg.norm(customers_nodepot[u] - customers_nodepot[v])
D_uv[u][v] = D_uv[v][u] = distance_uv
D_uv_normed = D_uv / np.max(D_uv)
print(D_uv_normed)
demands = vrp_instance["demand"][1:] # 0: depot
print(demands)
# 車両の設定
car_num = 5 # 車の台数
capa = vrp_instance["capacity"] # トラックの容量
次にpyquboに数式を書いていきます。
def make_pyqubo_model1(car_num, cus_num, capa, D_uv, demands):
x = Array.create("x", shape=(cus_num, car_num), vartype="BINARY")
y = Array.create("y", shape=(capa, car_num), vartype="BINARY")
H_A_1 = np.sum(
[
(1 - np.sum([y[i - 1, k] for i in range(1, capa + 1)])) ** 2
for k in range(car_num)
]
)
H_A_2 = np.sum(
[
(
np.sum([i * y[i - 1, k] for i in range(1, capa + 1)])
- np.sum([demands[v] * x[v, k] for v in range(cus_num)])
)
** 2
for k in range(car_num)
]
)
H_B = np.sum(
[(1 - np.sum([x[v, k] for k in range(car_num)])) ** 2 for v in range(cus_num)]
)
H_C = np.sum(
[
np.sum(
[
D_uv[u, v] * x[u, k] * x[v, k]
for u, v in itertools.combinations(range(cus_num), 2)
]
)
for k in range(car_num)
]
)
H = Placeholder("X") * Constraint(H_A_1, "H_A_1") + Placeholder("A") * Constraint(
H_A_2, "H_A_2"
)
H = H + Placeholder("B") * Constraint(H_B, "H_B") + Placeholder("C") * H_C
return H.compile()
model1 = make_pyqubo_model1(car_num, cus_num, capa, D_uv, demands)
feed_dict1 = dict(X=2500.0, A=50.0, B=2500.0, C=1.0) # いろいろ係数を調整してみてください
bqm1 = model1.to_bqm(feed_dict=feed_dict1)
print("num_variables:", bqm1.num_variables)
# SAの場合
from neal import SimulatedAnnealingSampler
sampler = SimulatedAnnealingSampler()
# D-Waveマシンの場合
# from dwave.system import DWaveSampler, EmbeddingComposite
# sampler_config = dict(solver="Advantage_system6.2", token="YOUR_TOKEN")
# sampler = EmbeddingComposite(DWaveSampler(**sampler_config))
sampler_params1 = dict(num_reads=10, num_sweeps=10000)
sampleset1 = sampler.sample(bqm1, **sampler_params1).aggregate()
sampleset1.to_pandas_dataframe().head()
one-hot制約や容量制約を満たしているか確認しましょう。Falseが表示される場合はその制約は満たされていないということを示しています。
# 制約を満たしているかどうかを確認する
decoded_samples1 = model1.decode_sampleset(sampleset1, feed_dict1)
for sample in decoded_samples1:
print(sample.energy)
print(sample.constraints(only_broken=True))
何回か制約項の係数を変えて実行してみてください。ある真実に気づくはずです。
そう。すべての制約項が0になるように係数を調整することが難しいということに!
(かなりの時間試しましたが、筆者は二つの制約項を0にすることしかできませんでした…)
考えられる原因としては以下のことが挙げられます。
- 3つの係数をバランスよく調整する必要がある。
- 顧客の要求量や顧客同士の距離に関してオーダーが異なる。
- そもそも不等式制約をそのまま解くことは難しい。
この段階で、論文に載っていた定式化で解くことは難しいということになり、新たな手法を取ることになりました。
Part2 容量の平均値に寄せる手法
次に用いた手法が2つ目の論文に載っている定式化です。
Part1と異なるのは容量制約に関する部分です。具体的には以下のようになります。
重みに関する制約
$$ H_D = D\sum_{k=1}^m\left(H_{D’} – \sum_{v=1}^n w_v x_v^k \right)^2$$
$$ H_{D’} = \frac{1}{m}\sum_{k=1}^m\left(\sum_{v=1}^n w_v x_v^k \right) $$
これは、すべての荷物の総和を車の台数で割ったもの(車に割り当てるべき容量の平均値)に近づける制約になっています。(この制約は0にはならないことに注意が必要)
この$H_D$を先ほどの$H_A$と置き換える形で解いていきます。
# 顧客の要求量に関して正規化する
demands_normed = demands / capa
print(demands_normed)
def make_pyqubo_model2(car_num, cus_num, capa, D_uv_normed, demands):
x = Array.create("x", shape=(cus_num, car_num), vartype="BINARY")
H_B = np.sum(
[(1 - np.sum([x[v, k] for k in range(car_num)])) ** 2 for v in range(cus_num)]
) # one-hot
H_C = np.sum(
[
np.sum(
[
D_uv_normed[u, v] * x[u, k] * x[v, k]
for u, v in itertools.combinations(range(cus_num), 2)
]
)
for k in range(car_num)
]
) # 距離
H_D = np.sum(
[
(
np.sum(demands) / car_num
- np.sum([demands[v] * x[v, k] for v in range(cus_num)])
)
** 2
for k in range(car_num)
]
) # balance
H = (
Placeholder("B") * Constraint(H_B, "H_B")
+ Placeholder("C") * H_C
+ Placeholder("D") * H_D
)
return H.compile()
model2 = make_pyqubo_model2(car_num, cus_num, capa, D_uv_normed, demands_normed)
feed_dict2 = dict(B=3, C=1, D=100)
bqm2 = model2.to_bqm(feed_dict=feed_dict2)
print("num_variables:", bqm2.num_variables)
上のコードの実行結果から補助変数がないため、変数の総数が一つ目の手法と比較して少なくなっていることが確認できます。
50のデータについてはB=3,C=1,D=100にすると容量制約を満たしたよい結果を得ることができました。
# SAの場合
sampler = SimulatedAnnealingSampler()
# D-Waveマシンの場合 サイズ50だと大きいため解けない
# from dwave.system import DWaveSampler, EmbeddingComposite
# sampler_config = dict(solver="Advantage_system6.2", token="")
# sampler = EmbeddingComposite(DWaveSampler(**sampler_config))
sampler_params2 = dict(num_reads=10, num_sweeps=10000)
sampleset2 = sampler.sample(bqm2, **sampler_params2).aggregate()
sampleset1.to_pandas_dataframe().head()
decoded_samples2 = model2.decode_sampleset(sampleset2, feed_dict2)
for sample in decoded_samples2:
print(sample.energy)
print(sample.constraints(only_broken=True))
筆者が実行した際は一番良いものでエネルギーが約42.8という結果が得られました。
def sample_to_assignment(sample, car_num, cus_num):
assignment = []
for m in range(cus_num):
for n in range(car_num):
if sample[f"x[{m}][{n}]"] == 1:
assignment.append((m, n))
return assignment
sample2 = decoded_samples2[0]
assignment2 = sample_to_assignment(sample2.sample, car_num, cus_num)
print(assignment2)
# 顧客がどのクラスターに属するかを示すリストを作成
belonged_cluster = [j for _, j in assignment2]
print(belonged_cluster)
容量が指定値を超えていないかを次のコードで確認しましょう。
def weight_check(belonged_cluster, demands, capa):
weights = [0] * len(set(belonged_cluster))
for i, cluster_i in enumerate(belonged_cluster):
weights[cluster_i] += int(demands[i])
for i, weight_i in enumerate(weights):
if weight_i > capa:
print(f"キャパオーバーです。(超過:{weight_i - capa})")
print("容量制約を満たしています。")
weight_check(belonged_cluster, demands, capa)
容量制約が満たされていることが確認出来たら、実際にクラスタリングされた結果を描画してみましょう。
# クラスタリングの結果を出力
plt.scatter(customers_nodepot[:, 0], customers_nodepot[:, 1], c=belonged_cluster)
plt.show()
記事と同じパラメーターで実行した場合、5つにきれいに色分けされた結果が得られると思います。
かなりよいクラスタリングの結果を得ることができました。
ルーティング
続いてこのデータをもとにルーティングを実行していきます。
元論文では、ルーティングを量子アニーリングマシンを利用して解いていましたが、今回はGoogleのOR-Toolsを利用して解いています。
# depotが入っている状態での顧客番号のリストを作成
clusters = [[0] for i in range(car_num)] # depotの番号は初めから追加しておく
for i in range(cus_num):
clusters[belonged_cluster[i]].append(i + 1) # depotが含まれている状態に戻すために数字を+1する
print(clusters)
# 同一クラスターにおける顧客どうしの距離を計算する
distance_matrices = []
for i in range(car_num):
cluster_size = len(clusters[i])
distance_matrix = np.zeros((cluster_size, cluster_size))
for j in range(cluster_size):
for k in range(j + 1, cluster_size):
distance_jk = np.linalg.norm(
customers[clusters[i][j]] - customers[clusters[i][k]]
)
distance_matrix[j][k] = distance_matrix[k][j] = distance_jk
distance_matrices.append(distance_matrix.tolist())
# ortoolsを利用して巡回セールスマン問題を解く
from ortools.constraint_solver import routing_enums_pb2
from ortools.constraint_solver import pywrapcp
def print_solution(manager, routing, solution, j):
index = routing.Start(0)
plan_output = f"Route for vehicle {j}:\n"
route_distance = 0
while not routing.IsEnd(index):
plan_output += " {} ->".format(manager.IndexToNode(index))
previous_index = index
index = solution.Value(routing.NextVar(index))
route_distance += routing.GetArcCostForVehicle(previous_index, index, 0)
plan_output += " {}\n".format(manager.IndexToNode(index))
plan_output += "Route distance: {}miles\n".format(route_distance)
print(plan_output)
return route_distance
def get_routes(solution, routing, manager):
routes = []
for route_nbr in range(routing.vehicles()):
index = routing.Start(route_nbr)
route = [manager.IndexToNode(index)]
while not routing.IsEnd(index):
index = solution.Value(routing.NextVar(index))
route.append(manager.IndexToNode(index))
routes.append(route)
return routes
solutions = []
route_dis = []
for j in range(len(clusters)):
distance_matrix = distance_matrices[j]
manager = pywrapcp.RoutingIndexManager(len(distance_matrix), 1, 0)
routing = pywrapcp.RoutingModel(manager)
def distance_callback(from_index, to_index):
from_node = manager.IndexToNode(from_index)
to_node = manager.IndexToNode(to_index)
return int(distance_matrix[from_node][to_node]) # ortoolsの仕様でintを返す
transit_callback_index = routing.RegisterTransitCallback(distance_callback)
routing.SetArcCostEvaluatorOfAllVehicles(transit_callback_index)
search_parameters = pywrapcp.DefaultRoutingSearchParameters()
search_parameters.first_solution_strategy = (
routing_enums_pb2.FirstSolutionStrategy.PATH_CHEAPEST_ARC
)
solution = routing.SolveWithParameters(search_parameters)
if solution is None:
print("No solution found !")
continue
# 解を表示する
route_dis.append(print_solution(manager, routing, solution, j + 1))
routes = get_routes(solution, routing, manager)
for i, route in enumerate(routes):
solutions.append(route)
ルーティングの答えを得ることができたので、実際にどんなルートになったのかを描画してみます。
import networkx as nx
# 解を視覚的に描画する関数
def print_routes(car_num, solutions, belonged_cluster, customers):
# 元のデータ番号での呼び出し
solutions2 = [[] for i in range(car_num)]
for i in range(len(solutions)):
for j in range(len(solutions[i])):
solutions2[i].append(clusters[i][solutions[i][j]])
# 各顧客について色を指定する
cm = plt.get_cmap("tab20")
node_list = [(0, {"color": cm(0)})]
for i in range(len(belonged_cluster)):
node_list.append((i + 1, {"color": cm(belonged_cluster[i] + 1)}))
# depotおよび顧客の番号と位置に関する辞書を作成
pos = {}
for i in range(len(customers)):
pos[i] = (customers[i][0], customers[i][1])
# ルーティングで得られた解をもとに実際に通る辺のリストを作成
edge_list = []
for i in range(len(solutions2)):
for j in range(len(solutions2[i]) - 1):
edge_list.append((solutions2[i][j], solutions2[i][j + 1]))
g = nx.DiGraph()
g.add_nodes_from(node_list)
g.add_edges_from(edge_list)
node_color = [node["color"] for node in g.nodes.values()]
plt.figure(figsize=(8, 8)) # 見やすいようにサイズを変更してください
nx.draw_networkx(g, pos, with_labels=True, node_color=node_color)
plt.show()
print_routes(car_num, solutions, belonged_cluster, customers)
OR-Toolsの○○mileという表記は小数点以下が切り捨てられているので、正しい距離を計算します。
# 各ルートの距離を計算する
distance_sum = []
for i in range(len(solutions)):
sum = 0
for j in range(len(solutions[i]) - 1):
sum += distance_matrices[i][solutions[i][j]][solutions[i][j + 1]]
distance_sum.append(sum)
for i in range(len(distance_sum)):
print(f"{i+1}番目のルートの距離の総和: {distance_sum[i]}")
print(np.sum(distance_sum))
bks = vrp_solution["cost"] # 厳密解
ans = np.sum(distance_sum)
print((ans - bks) / bks * 100, "%")
厳密解(BKS)との偏差は+約2.4%という結果になりました。
元論文のハイブリッドな手法では、厳密解との偏差が5.98%であったので、かなり良い結果を得られたといえるでしょう!
しかし、よりサイズの大きいデータセットを解いてみようとすると、容量制約を満たしてもクラスタリングがうまくいかず、厳密解の偏差も元論文の手法より悪い結果となりました。(詳細は省きます。)
まとめ
今回実際に論文で扱われていたCMT1というデータセットをクラスタリングはSA、ルーティングはGoogleのOR-Toolsを使って解きました。
CMT1に対しては厳密解を得ることはできませんでしたが、厳密解にかなり近い解を得ることができました。しかし、サイズの大きいデータセットに対しては制約を満たす良い解を得ることが難しかったです。
今後の展望としては、SAによるクラスタリング後にクラスタリングを改善するアルゴリズムを導入するといったことが考えられます。
あとがき
今回実際に論文の問題を解くにあたっていくつか壁に当たりました。
- 論文通りにアルゴリズムを実装するも容量がきつきつで、荷物の入れ替わりが起こらずクラスタリングがうまくいかない。
- 自分で量子アニーリングマシンを利用して解こうとするも制約を満たしてくれない。
- 論文の定式化通りの実装をしてもクラスタリングがうまくいかない。
といった感じです。何回か心が折れましたが、実際にかなり良い結果が得られ、それを描画した時には達成感がありました。
今後も同様に再現実験を通してコーディングの技術を磨いていきたいです。
また、今回2本目として紹介した論文や、容量制約で用いた不等式制約に関する論文を読んで、CVRPという問題に対して知識を増やしていきたいです。実社会に即した問題を解けるように頑張りたいと思います!