会議割り当て問題¶
会議割り当て問題は、複数の会議のスケジュールと複数の会議室が与えられている場合に、なるべく多くの会議が開催できるように会議室を割り当てる問題です。この問題は組合せ最適化問題であり、最適化することなく非効率に会議室を割り当てた場合、以下のような問題が発生する場合があります。
- 会議室の数が足りず、会議室をすべての会議に割り当てることができない
- 会議の途中に不必要に会議室を移動しなければならない
組合せ最適化ライブラリである Amplify を用いて会議の割り当てを最適化することで、上記のような問題ができるだけ起こらないようにすることを試みます。
データとして、各会議の情報および利用可能な会議室の数が与えられているとします。各会議の情報は、会議名をキーとし開始時間と終了時間のタプルを値とする辞書の形式で与えられ、時間は文字列で
"10:40"
の形式で与えられます。さらに会議の数も定義しておきます。
# 会議のスケジュール
schedules = {
"meeting1": ("10:00", "13:00"),
"meeting2": ("10:00", "12:00"),
"meeting3": ("10:00", "11:00"),
"meeting4": ("11:00", "13:00"),
"meeting5": ("11:00", "12:00"),
"meeting6": ("11:00", "15:00"),
"meeting7": ("12:00", "16:00"),
"meeting8": ("12:00", "15:00"),
"meeting9": ("13:00", "15:00"),
"meeting10": ("13:00", "14:00"),
"meeting11": ("14:00", "17:00"),
"meeting12": ("15:00", "19:00"),
"meeting13": ("15:00", "17:00"),
"meeting14": ("15:00", "16:00"),
"meeting15": ("16:00", "18:00"),
"meeting16": ("16:00", "18:00"),
"meeting17": ("17:00", "19:00"),
"meeting18": ("17:00", "18:00"),
"meeting19": ("18:00", "19:00"),
}
# 会議の数
num_meetings = len(schedules)
# 会議室の数
num_rooms = 8
"10:40"
のような形式の文字列を単一の数値に変換する関数を用意しておきます。
# 時刻を時間単位の数値に変換する関数
def time2num(time: str):
h, m = map(float, time.split(":"))
return h + m / 60
以下では、上記で定義した 19 (= num_meetings
) 個の会議を 8 (= num_rooms
)
個の会議室にどのように振り分ければすべての会議を開催できるのかを解いていくことになります。まず、どのようにしてこの問題を組合せ最適化問題として表現するかを考えます。
まず基本方針として、それぞれの会議がどの会議室で開催されるかを変数で表すようにします。各会議ごとに整数変数を用意し、整数変数の値が $r$ であることがその会議が $r$ 番目の会議室で開催されることを意味するようにするという方法も考えられますが、この方法は割り当て問題を多項式の最適化として表すには不向きです。今回は、各会議ごとに会議室の数だけバイナリ変数を割り当て、その会議がどの会議室で開催されるのかを表現します。
つまり、変数の数は全部で num_meetings
$\times$ num_rooms
個となります。会議 $i$ を 会議室 $r$
で行うことを表す変数を $q_{i, r}$ とします。$q_{i, r} = 1$ であれば会議 $i$ は会議室 $r$ に割り当てられ、$q_{i, r} = 0$
であれば割り当てられないことになります。特に、同じ会議に割り当てられている num_rooms
個の変数は、そのうちちょうど 1 つが $1$ である必要があります。
会議 \ 会議室 | 会議室 A | 会議室 B | $\cdots$ | 会議室 H |
---|---|---|---|---|
meeting 1 |
$q_{0,0}$ | $q_{0,1}$ | $\cdots$ | $q_{0,7}$ |
meeting 2 |
$q_{1,0}$ | $q_{1,1}$ | $\cdots$ | $q_{1,7}$ |
$\vdots$ | $\vdots$ | $\vdots$ | $\cdots$ | $\vdots$ |
meeting 19 |
$q_{19,0}$ | $q_{19,1}$ | $\cdots$ | $q_{19,7}$ |
次に、同じ会議室には複数の会議を重ねて割り当てることができないという制約をどのように表すかについて考えます。
前処理として、スケジュールの重なりのある会議をまとめたリストを構築します。例えば、会議 $i$ と $j$ にスケジュールの重なりがある場合、タプル $(i, j)$ をこのリストに格納します。こうすれば、「スケジュールの重なりが無いように各会議を会議室に割り当てる問題」は、「二つの会議 $(i, j)$ が上記のリストに含まれる場合、同じ会議室を割り当てないように会議を配置する問題」となります。
さらに、「二つの会議 $(i, j)$ が上記のリストに含まれる場合、同じ会議室を割り当てないように会議を配置する問題」は、「二つの会議 $(i, j)$ が上記のリストに含まれる場合、どの会議室 $r$ についても、$q_{i, r}$ と $q_{j, r}$ が両方 $1$ にならないように変数 $q$ の値を決定する問題」と言い換えることができ、これは Amplify の機能を用いて書くことができます。
以上で、会議室割当問題を Amplify を用いて解く方針が立ちました。
前処理として、スケジュールの重なりのある会議をまとめたリストを構築します。二つの会議のスケジュールが重なりあうかどうかをチェックする関数 check_overlap
を用意して、それをもとにスケジュールに重なりがある二つの会議 $(i, j)$ を overlaps
リストに追加していきます。
# 2つの会議時間に重なりがあるかをチェック
def check_overlap(meeting_name1: str, meeting_name2: str) -> bool:
start1, end1 = map(time2num, schedules[meeting_name1])
start2, end2 = map(time2num, schedules[meeting_name2])
return start1 < end2 and start2 < end1
import itertools
# 会議名のリストを取得
mtg_names = list(schedules.keys())
# スケジュールの重なりがある会議のインデックスをタプルで格納
overlaps = [
(idx1, idx2)
for idx1, idx2 in itertools.combinations(range(num_meetings), 2)
if check_overlap(mtg_names[idx1], mtg_names[idx2])
]
次に、決定変数を用意します。num_meetings
$\times$ num_rooms
個のバイナリ変数を 2 次元配列の形式で生成します。会議
$i$
が会議室 $r$ で開催されるかどうかに対応する変数は q[i, r]
となります。
from amplify import VariableGenerator
gen = VariableGenerator()
q = gen.array("Binary", shape=(num_meetings, num_rooms))
バイナリ変数の間に課せられる制約条件を作成します。
まず、「同じ会議に割り当てられている num_rooms
個の変数は、そのうちちょうど 1 つが $1$ である必要がある」という制約条件を課します。数式では
$ \displaystyle\sum_r q_{i, r} = 1 \quad \text{for all} \; i $
と書くことができ、amplify では one_hot
制約として表現できます。2 次元のバイナリ変数配列に対して、それぞれの行の和が 1 であることを表すには
one_hot
関数の axis
キーワード引数に $1$ を与えればよいです。
from amplify import one_hot
room_constraints = one_hot(q, axis=1)
さらに、二つの会議のインデックス $(i, j)$ が、 先ほど定義した会議スケジュールの重なりリスト overlaps
に含まれている場合は、同じ会議室を割り当てることができないという制約を与える必要があります。
これは $(i, j)\in \text{overlaps}$ である場合は $q_{i, r}$ と $q_{j, r}$ が同時に $1$ にならないという制約条件であるので、
$ q_{i, r} q_{j, r} = 0 \quad \text{for} \; (i, j) \in \text{overlaps}, r \in \{0, \cdots, N_r - 1\} $
と書くことができます。
Amplify では等式制約を表現するために equal_to
関数を用いることができます。overlaps
の要素
(i, j)
について、まず q[i, :] * q[j, :]
とすることで一次元配列
[q[i, 0] * q[j, 0], q[i, 1] * q[j, 1], ...]
を生成し、この一次元配列の要素がすべて 0
となるような等式制約を生成します。配列の要素それぞれに対して等式制約を作成するためには、equal_to
関数の axis
パラメータに空タプル
()
を与えます。
from amplify import equal_to, sum as amplify_sum
# overlaps内の全ての (i, j) で、q[i, r] * q[j, r] = 0 の制約条件を課す
overlap_constraints = amplify_sum(
equal_to(q[i, :] * q[j, :], 0, axis=()) for (i, j) in overlaps
)
上記で生成した二つの制約条件オブジェクト room_constraints
と overlap_constraints
を結合し、最終的に解くべき組合せ最適化モデルとします。
model = room_constraints + overlap_constraints
次に、クライアントを設定し、定義した模型を解きます。
from amplify import FixstarsClient, solve
from datetime import timedelta
# クライアントを設定
client = FixstarsClient()
# client.token = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" # ローカル環境で使用する場合は、Amplify AEのアクセストークンを入力してください
client.parameters.timeout = timedelta(milliseconds=1000) # タイムアウト1秒
# 求解の実行
result = solve(model, client)
# result が空の場合、制約条件を満たす解が得られなかったことを示す
if len(result) == 0:
raise RuntimeError("Given constraint conditions are not satisfied")
求められた解における各変数の値は、以下のようにして取得できます。
# 求めた解を元の変数に代入
solution = q.evaluate(result.best.values)
solution
解をより見やすい形にします。上記 solution
と一次元配列 [0, 1, 2, ...]
の行列積をとることで、それぞれの会議に割り当てられる会議室のインデックスからなる配列を取得することができます。
import numpy as np
# room_list[i] は会議 i に割り当てられる会議室のインデックスとなる
room_list = (solution @ np.arange(num_rooms)).astype(int)
# 会議名と会議室インデックスの辞書を作成
room_assignment = {
meeting_name: room_idx for meeting_name, room_idx in zip(mtg_names, room_list)
}
room_assignment
結果を可視化します。
#
# 会議室割り当てを可視化
#
%matplotlib inline
import string
import matplotlib
import matplotlib.pyplot as plt
def plot_mtg_schedule():
room_names = [f"Room {c}" for c in string.ascii_uppercase[:num_rooms]]
cmap = matplotlib.colormaps["coolwarm"].resampled(num_rooms)
fig, ax = plt.subplots(nrows=1, ncols=1, figsize=(15, 10))
for mtg_name, room in room_assignment.items():
start = time2num(schedules[mtg_name][0])
end = time2num(schedules[mtg_name][1])
ax.fill_between(
[room + 0.55, room + 1.45],
start,
end,
edgecolor="black",
linewidth=3.0,
facecolor=cmap(room),
)
ax.text(
room + 0.6, start + 0.1, f"{schedules[mtg_name][0]}", va="top", fontsize=10
)
ax.text(
room + 1.0,
(start + end) * 0.5,
mtg_name,
ha="center",
va="center",
fontsize=15,
)
# Set First Axis
ax.yaxis.grid()
ax.set_xlim(0.5, len(room_names) + 0.5)
ax.set_ylim(19.1, 7.9)
ax.set_xticks(range(1, len(room_names) + 1))
ax.set_xticklabels(room_names)
ax.set_ylabel("Time")
# Set Second Axis
axis_x = ax.secondary_xaxis("top")
axis_y = ax.secondary_yaxis("right")
axis_x.set_xticks(ax.get_xticks())
axis_x.set_xticklabels(ax.get_xticklabels())
axis_y.set_ylabel("Time")
plt.show()
plot_mtg_schedule()