みなさんは数理最適化や最適化問題ということばをご存知でしょうか。
おそらく「最適化ということばは、よく聞くけれど詳しくはしらない」という方が多いのではないかと思います。
数理最適化という道具を用いて作成する問題を最適化問題と呼びます。最適化問題は、考えないといけない制約が複数ある状態で、ある目的を最大・最小にしたいオーダがある際に役立ちます。実際に具体例を挙げてみます。
想像していたより実社会における適用範囲が広いと感じたのではないでしょうか。
今回は、数理最適化のことをより深く知っていただけるように、2部構成の記事を組みました。
第一部では数理最適化の特徴や種類のお話、最適化と密接に関係してくるソルバのお話をしたあと、有名な「どろぼうの問題(ナップザック問題)」を通して最適化への理解を深めることができる流れになっています。
第二部目では第一部にて学んだ内容を踏襲しつつ、著者が作成したオリジナル問題を通してより汎用的な最適化問題を解き、興味・理解を深めていただければと思っています。
第一部をまだご覧になっていない方は以下からごらんください。
前回記事:「[最適化問題のすゝめ:第1部]数理最適化とは?種類や特徴を解説」
- Contents -
ここからは第一部で学んだことを思い出しつつ、著者が作成したオリジナル問題を用いて最適化問題についてより深く学んでいきましょう。最適化問題を解く構成は、おおまかに「定式化→モデリング→最適化計算→解分析」の流れになっていると第一部で話しましたね。しかし、有用な最適化問題を構成するためには、パラメータ調整や新たな制約追加・削除を何度か繰り返し行う必要性もあります。
ではどうすればいいかと言いますと、そのようなパラメータ調整自体をユーザ自身にコントロールしてもらえるように仕様を構成するというのはどうでしょうか。このような方法も一つの手ではないかと考えています。著者は、後ほど説明するオリジナル最適化問題にて、Streamlitと呼ばれるフロントエンドアプリを作成できるpythonのフレームワークを用いて、ユーザ自身にパラメータを調整してもらう仕様にしました。さらに入力データ内容や出力結果も目視してもらえるようにしました。そうすることで、ユーザの手元でいろいろなパラメータ値で試した結果を目で見てもらえるという利点があります。
ここからは著者が考えたシフトスケジュール問題について説明します。では、シフトスケジューリング問題の設定をみていきましょう。
定式化の作業にうつる前、まずは最適化問題フレームワークを構成する上で、使用する環境の方針や前提条件を決めておきましょう。
上記の問題文から定式化を行います。問題を定式化すると下記のようになります。問題文と照らし合わせてみてください。
制約条件(2.1)~(2.3)は、平日シフトの出勤人数の上下限、土日シフトの出勤人数の下限、毎日のシフトに必要なPGMの下限人数を設定する制約です。それぞれの制約の上下限値をパラメータとして定義しています。こうすることで、ユーザ入力から受け付けた内容を反映させる狙いがあります。また、それぞれの制約は、問題文にかかれている考慮したい事項の(い)~(は)の要望に対応しており、メンバiがj曜日に働くとき1,働かないなら0を意味する0-1変数であるxijと集合の要素をうまく利用して式を表現しています。
制約条件(2.4)は、シフト希望採用回数の上限・下限を設定する制約です。メンバはシフト提出時に働くことができる曜日を提出しています。実際にシフトに入る(働く)ことが決定した曜日の日数に応じてxijが変化します。例えば、メンバの1人であるAyakaが一週間のシフトで月曜日と火曜日のみ働くなら となり、 となります。つまりを変数u, lで挟むことでシフト希望採用回数の上下限を得ることができる仕組みです。
制約条件(2.5)は、シフト希望日以外の出勤を禁止する制約です。予め提出したシフト上で働けない曜日として提出しているにも関わらず、最適化計算後に働くことになっていては困りますよね。そのような事態をさけることができるように制約を設定する必要があります。制約にでてくる(sij xij)の組合せは、(1 1),(1 0), (0 1), (0 0)の4つあります。この4つの中から「シフト希望で入れない日(つまりsij=0)として提出したのに、実際働くことになった(つまりxij=1)」場合を除けば都合が良くなります。つまり、sij-xij ≥ 0と設定することで、(sij xij)= (0 1)の場合を除く制約にすることができますね。
制約条件の(2.6)は、「どろぼうの問題」でもでてきた通りxijが0-1変数であるという意味を示す制約です。
最後に目的関数です。目的関数は制約条件(2.4)で設定された変数u, lの差分を最小にする制約です。問題文にかかれている「メンバーには平等でいて欲しいため、シフト希望回数が通る回数の隔たりがなるべく少なくすむようにしたい」という要望に対する内容です。一般的に隔たりをなくす(いわゆる平準化)場合、最大値の最小化もしくは最小値の最大化などいろいろなアプローチがありますが、今回は最大値と最小値の差分を最小にすることで平準化を実現することにしました。
「②APIを利用してソルバ計算」を採用した場合における、モデリングを見てみましょう。まずは記号を定義した部分です。
# シフト情報の読み込み
df_in = pd.read_csv("../input_data/提出済みDSシフトスケジュール問題.csv", index_col=0)
# 最適化問題の宣言
m = LpProblem("DS_Shift-ScheduleProb")
#### 集合
# 人の集合
I = df_in.index.values.tolist()
# PGMの集合
I_ = I[8:11]
# 曜日の集合
J = df_in.columns.tolist()
# 祝日の集合
J_ = J[5:7]
#### パラメータ
# 提出したシフト
shift = df_in.values.tolist()
s = {} # 空の辞書
for i in I:
for j in J:
s[i, j] = shift[I.index(i)][J.index(j)]
#### 変数
up = LpVariable("u", lowBound=0)
low = LpVariable("l", lowBound=0)
x = {}
for i in I:
for j in J:
x[i, j] = LpVariable(f"x({i},{j})", cat=LpBinary)
この部分では、提出されたシフト情報を読み込み集合やパラメータ値を設定しています。前提条件で決めたように提出済みのシフトにはメンバーの希望情報が0か1で記入されています。上記の提出済みシフト情報をpythonのデータフレームとして取得し、メンバと曜日の要素を読み込みます。さらに得られたメンバのうちPGMメンバを指定し、曜日も同様にして土日の設定を行っています。また、どのメンバーがどの曜日に働く・働かないのかという情報を辞書データとして取得していることがわかります。
次に定式化の部分です。
#### 目的関数
m += (up - low)
#### 制約条件
st.header('▼ 作成するシフトの設定')
# 出勤人数の上限
LB_shift = st.slider(
'① 出勤人数の下限を決めてください', min_value=0, max_value=10, step=1, value=1)
st.write("→ 毎日、少なくとも", LB_shift, '人は出勤します。')
# 出勤人数の下限
UB_shift = st.slider(
'② 出勤人数の上限を決めてください', min_value=0, max_value=10, step=1, value=4)
st.write("→ 毎日、多くても", UB_shift, '人まで出勤します。')
for j in J:
# 1日少なくとも*人は出勤、多くても*人まで
m += lpSum(x[i, j] for i in I) >= LB_shift, f"LB_shift_{j}"
m += lpSum(x[i, j] for i in I) <= UB_shift, f"UB_shift_{j}"
# PGMは少なくとも1人毎日出勤する
UB_PGM_shift = st.slider(
'③ PGMの下限を決めてください', min_value=0, max_value=2, step=1, value=1)
st.write("→ 毎日、少なくとも", UB_PGM_shift, '人はPGMが出勤します。')
for j in J:
m += lpSum(x[i, j] for i in I_) >= UB_PGM_shift, f"PGM_shift_commit_{j}"
# 土日の出勤人数の下限
LB_sunsat_shift = st.slider(
'④ 土曜日・日曜日の出勤人数の下限を決めてください', min_value=LB_shift, max_value=UB_shift, step=1, value=3)
st.write("→ 土日は、少なくても", LB_sunsat_shift, '人は出勤します。')
# 土日は*人以上出勤する
for j in J_ :
m += lpSum(x[i, j] for i in I) >= LB_sunsat_shift, f"LB_shift_SunSat_{j}"
##### その他の制約
# シフト希望が通る回数の下限と上限
for i in I:
m += lpSum(x[i, j] for j in J) >= low , f"LB_Staff_Satisfy_{i}"
m += lpSum(x[i, j] for j in J) <= up , f"UB_Staff_Satisfy_{i}"
# シフト希望日以外は出勤しない
for i in I:
for j in J:
m += s[i, j] >= x[i, j], f"Shift_({i}{j})"
「どろぼうの問題」と違うポイントとしては、変数の添え字が変化したことでしょう。添え字が1つから2つになっているため、Pythonコード中のforループが2重になっている箇所があることがわかりますね。また、Streamlitを使用して、ユーザ入力から受け付けたパラメータ情報を制約条件の内容として反映するような書き方になっていることも、ポイントになっています。このように記号の定義、制約条件と目的関数の設定を行うことでモデリングが完成しました。
では、実際にどのようなインターフェースでユーザからの入力受付がおこなわれるのかを少しお見せします。それぞれのパラメータ値をスライダーで調整できるようになっています。そしてユーザが設定したスライダーの値から最終的に採用する値を再確認している様子がわかります。
上記の設定が終了すれば、最適化計算をスタートできます。「最適化計算スタート」ボタンを押せば、裏で最適化計算がまわりはじめます。
解がみつかれば、結果が出力されてユーザは目視して確認することができます。
最後に最適化計算後の出力についてお話しします。出力までの過程は以下のイメージのようになっています。
入力内容である提出済みのシフト希望のデータと最適化問題の構成に必要なユーザからの入力情報を利用して最適化計算を行います。計算後に、どのメンバがどの曜日に働くかどうかの情報を計算後の変数値を利用すれば、うまく取得できるはずですね(0と1のデータで表現されています)。
まずは、取得できた情報を一時的な出力としてテキストファイルに書き出します。さらに、そのテキストファイルの内容を用いて、あらかじめメンバと曜日が書かれた中身の埋まっていないデータの中身の値を埋める作業を行っています。このような命令を下記Pythonコードで表現しています。
# 書き込み設定
if m.status == 1:
st.markdown("→ 実行可能解が見つかりました!")
# 計算結果 → テキストファイル
with open("../output_data/result_ShftSchedule.txt", "a", newline="") as file:
for i in I:
for j in J:
print(x[i,j].value(), file=file, end="\n")
# テキストファイルのデータ → エクセル
book = openpyxl.load_workbook('../output_data/result_ShftSchedule.xlsx', data_only = False)
sheet = book['result']
file_data = open("../output_data/result_ShftSchedule.txt", "r", encoding="utf-8")
# 指定箇所から書き込み
for j in range(2, 13):
for i in range(2, 9):
pattern_data = file_data.readline().rstrip()
# エラー処理(ファイルが空の場合はskipする)
try:
sheet.cell(row=j, column=i).value = str(pattern_data)
except :
continue
# ファイル出力
filename, file_extension = os.path.splitext("../output_data/result_ShftSchedule.txt")
out_file = filename + ".xlsx"
book.save(out_file)
いかがでしょか、最適化問題について少し興味がわいてきたでしょうか。ここまでの内容を通して、様々なことを簡単にですが説明してきました。加えて、忘れてはいけないこととして、最終的に出力されて結果が本当に正しいかどうかを確認する作業があります。つまり正しく制約が機能しているかどうかを確かめる作業になります。確かめるべきポイントとして何点か挙げてみました。
例えば「提出したシフトで0(出勤できない)と指定したのに1(働く)になっていないか」の確認はstreamlit上で表示される内容を見比べてみればわかりますね。その他のことは、公開リポジトリに同じ環境を準備してますので、ぜひご自身の手で確かめてみてください。
最後になりますが、ポイントをまとめて記事を締めさせていただけきます。
執筆者:山本