【これでわかる!】整数計画問題として定式化した割当問題をpythonで解く方法を一つずつ丁寧に解説してみた


こんにちは!しゅんです!

今回の記事では割当問題ついて解説していきます!

割当問題とはという数理最適化で登場する用語で、現実世界の様々な分野で応用できます。



例えば10人の従業員を10個の仕事に割り当てる場合に、どのように割り当てたら効率的なのかを考えるのが割当問題です。従業員1は仕事4が得意だけど、逆に従業員3は仕事2が苦手みたいなことはよくあります。

この記事では割当問題をpythonで解く方法について解説したいと思います。



それではやっていきましょう!



普段は統計検定2級の記事を書いてたりします。
ぜひ他の記事も読んでみてください!



このブログの簡単な紹介はこちらに書いてあります。
興味があったら見てみてください。

このブログでは経営工学を勉強している現役理系大学生が、経営工学に関することを色々話していきます!


ぼくが経営工学を勉強している中で感じたことや、興味深かったことを皆さんと共有出来たら良いなと思っています。


そもそも経営工学とは何なのでしょうか。Wikipediaによると

経営工学(けいえいこうがく、英: engineering management)は、人・材料・装置・情報・エネルギーを総合したシステムの設計・改善・確立に関する活動である。そのシステムから得られる結果を明示し、予測し、評価するために、工学的な分析・設計の原理・方法とともに、数学、物理および社会科学の専門知識と経験を利用する。

引用元 : 経営工学 – Wikipedia

長々と書いてありますが、要は経営、経済の課題を理系的な観点から解決する学問です。



今回解く問題


今回は以下の問題を解きたいと思います。


割当問題の詳しい説明は前回の記事で解説しているのでここでは簡単に説明したいと思います。図の左側が従業員で右側が仕事です。今回は4人の従業員を4つの仕事に割り当てることを考えます。

\\\ 前回の記事はこちらから! ///



各従業員はそれぞれ1つの仕事を担当し、どの仕事も必ず従業員の誰かに割り当てられます。そして各従業員はそれぞれの仕事に対してどれくらい仕事をやりたくないかという値を持っています。

上の例で言うと例えば従業員1と仕事2を結ぶ線の上には8と書いてありますが、これは従業員1の仕事2に対する仕事やりたくない度は8であるということを表しています。


今やりたいのは、従業員全員の仕事やりたくない度の合計が最小になるような従業員と仕事の組み合わせを求めることです。

この記事ではこれをpythonで解く方法を解説したいと思います。


整数計画問題として定式化したものをpythonで実装する


今回は割当問題を整数計画問題として定式化したものを、pythonで実装してみたいと思います。前回の記事で、割当問題は最小重み完全マッチング問題としてみなせるという話をしました。


最小重み完全マッチング問題を整数計画問題として定式化する方法はこれまでの記事で解説しているのでここでは結果だけ載せておきます。

整数計画問題として定式化:

\(\min \;\;\; \sum\limits_{i=1}^4 \sum\limits_{j=1}^4 w_{ij}x_{ij}\)
\(\;\text{s.t.}\;\;\;\sum\limits_{j=1}^4 x_{ij}=1 \;\;\;\; (i = 1,2,3,4)\)
\(\;\;\;\;\;\;\;\;\;\sum\limits_{i=1}^4 x_{ij}=1 \;\;\;\; (j = 1,2,3,4)\)
\(\;\;\;\;\;\;\;\;\; x_{ij} \in \{0,1\} \;\;(i=1,2,3,4 \; j=1,2,3,4)\)


\(w_{ij}\) : 辺 \((i,j)\) の重み




コードの全貌と実行結果


まず最初にコードの全貌実行結果を示してから、コードと結果について1つずつ解説していきたいと思います。コードの全貌は以下のようになります。

## 事前準備
from pulp import * # pulpをインポート

## パラメータの設定
v1 = [0,1,2,3] # 従業員側の頂点を設定
v2 = [0,1,2,3] # 仕事側の頂点を設定
w = [[3,8,1,2],[3,2,4,5],[3,1,2,4],[7,4,2,2]] # 各辺の重みを設定

## 整数計画問題の設定
prob = pulp.LpProblem(sense = LpMinimize) # 問題をMinimizeに設定
x = LpVariable.dict("x",(v1,v2), cat = "Binary") # 0-1変数の設定
prob += lpSum(x[i,j] * w[i][j] for i in v1 for j in v2) # 目的関数の設定
for i in v1:
  prob += lpSum(x[i,j] for j in v2) == 1 # 従業員側の頂点に関する制約条件を設定
for j in v2:
  prob += lpSum(x[i,j] for i in v1) == 1 # 仕事側の頂点に関する制約条件を設定
result = prob.solve() # 問題を解く

## 結果の表示
print(LpStatus[result]) # 得られた結果の状態を表示(Optimalなら最適解が得られている)
for i in v1:
  for j in v2:
    if x[i,j].value() == 1:
      print(f"x{i}{j} =",x[i,j].value()) # 最適解(変数の値が1のものだけ)を表示
print("最適値 :", prob.objective.value()) # 最適値を表示

このコードを実行すると以下のような結果が得られました。


それでは1つずつ解説していきます。


コードを1つずつ解説する


事前準備

## 事前準備
from pulp import * # pulpをインポート

問題を解くための事前準備としてpulpをインポートします。pulpはpythonで数理最適化の問題を解くために使うライブラリです。


パラメータの設定

## パラメータの設定
v1 = [0,1,2,3] # 従業員側の頂点を設定
v2 = [0,1,2,3] # 仕事側の頂点を設定
w = [[3,8,1,2],[3,2,4,5],[3,1,2,4],[7,4,2,2]] # 各辺の重みを設定

次に各パラメータを設定していきます。今回は完全2部グラフを考えているので頂点と各辺の重みを設定するだけでOKです。


「v1」が従業員側の頂点「v2」が仕事側の頂点を表しています。

もっと深堀り!


pythonでは数字が0からスタートするので[1,2,3,4]ではなく[0,1,2,3]にしています。


「w」は各辺の重さを2次元リスト形式で表しています。例えば「w[0][1]」は「w」の1行2列目の数字8を表しますが、これは辺 \((1,2)\) の重みが8であることを表しています。つまり従業員1の仕事2に対する仕事やりたくない度は8であることを表しています。


整数計画問題の設定

## 整数計画問題の設定
prob = pulp.LpProblem(sense = LpMinimize) # 問題をMinimizeに設定
x = LpVariable.dict("x",(v1,v2), cat = "Binary") # 0-1変数の設定
prob += lpSum(x[i,j] * w[i][j] for i in v1 for j in v2) # 目的関数の設定
for i in v1:
  prob += lpSum(x[i,j] for j in v2) == 1 # 従業員側の頂点に関する制約条件を設定
for j in v2:
  prob += lpSum(x[i,j] for i in v1) == 1 # 仕事側の頂点に関する制約条件を設定
result = prob.solve() # 問題を解く


それではいよいよ整数計画問題をpythonで記述していきたいと思います。少し長いのでさらにいくつかのパートに分けて説明したいと思います。


問題をMinimizeに設定

prob = pulp.LpProblem(sense = LpMinimize) # 問題をMinimizeに設定

この行では問題の種類を設定しています。「pulp.LpProblem」で数理最適化の問題を作ることができます。かっこの中の「sense=」の所で問題の種類を設定できます。今回は最小値を求めたいので「LpMinimize」と設定します。

もっと深堀り!


最大値を求めたい場合はLpMaximizeとします。


0-1変数の設定

x = LpVariable.dict("x",(v1,v2), cat = "Binary") # 0-1変数の設定


次に0-1変数 \(x_{ij}\in\{0,1\}\) を設定しています。変数は「LpVariable」で設定できます。変数をたくさん作りたい場合は「.dict」を使うと便利です。かっこの中は左から順番に(”変数の名前” 添字の集合, cat=”変数の種類”)を表しています。


上の例で言うと名前はx、添字の集合は左側の頂点集合「v1」と右側の頂点集合「v2」、変数は「Binary」(0-1変数)と設定しています。

Binary : 0-1変数
Integer : 整数変数
Continuous : 連続変数



目的関数の設定

prob += lpSum(x[i,j] * w[i][j] for i in v1 for j in v2) # 目的関数の設定

これは本筋ではありませんが、目的関数と制約条件は「prob+=」を最初に付けます。「prob」は問題を「Minimize」に設定したときに「prob=」としたのでそれに合わせます。もし「Minimize」の設定の所で

problem = pulp.LpProblem(sense = LpMinimize) # 問題をMinimizeに設定

と書いたら目的関数と制約条件の設定の所では「problem+=」と書く必要があります。


肝心の目的関数の設定についてですが、\(\sum\limits w_{ij}x_{ij}\) を表すには「lpSum」が便利です。イメージ的には「lpSum」が \(\sum\) の役割をしてくれると思ってください。


「for i in v1 for j in v2」と書くことで \(\sum\limits^4_{i=1}\sum\limits^4_{j=1}\) を表現することができます。


制約条件の設定

for i in v1: 
  prob += lpSum(x[i,j] for j in v2) == 1 # 従業員側の頂点に関する制約条件を設定
for j in v2:
  prob += lpSum(x[i,j] for i in v1) == 1 # 仕事側の頂点に関する制約条件を設定


次に制約条件を設定していきます。まず従業員側の頂点に関する制約

\(\sum\limits^2_{j=0}x_{ij}=1 \;\; (i = 0,1,2,3)\)

を設定します。先ほどと同じように \(\sum\) は「lpSum」で表現します。かっこの中を「(x[i,j] for j in v2)」とすることで
\(\sum\limits^2_{j=0}x_{ij}\)
を表現することができます。(「v2=[0,1,2]」)
これでが1になれば良いので「==1」としています。

= 1にするとエラーが出るので気を付けてください。


右側の頂点に対しても同じように制約条件を設定します。


問題を解く

result = prob.solve() # 問題を解く


「prob.solve()」で問題を解くことができます。結果を「result」に格納しておきます。


結果の表示

## 結果の表示
print(LpStatus[result]) # 得られた結果の状態を表示(Optimalなら最適解が得られている)
for i in v1:
  for j in v2:
    if x[i,j].value() == 1:
      print(f"x{i}{j} =",x[i,j].value()) # 最適解(変数の値が1のものだけ)を表示
print("最適値 :", prob.objective.value()) # 最適値を表示


最後に結果を表示するコードについて説明したいと思います。まず「print(LpStatus[result])」で得られた結果の状態を表示します。もし「Optimal」と返されたらちゃんと最適値が求まっています。


次に最適解、すなわち \(x_{ij}=1\) を満たす変数 \(x_{ij}\) を表示するように設定します。解の値は「.value()」で得ることができます。

今回は \(i=0,1,2,3 \;j=0,1,2,3\) に対して \(x_{ij}=1\) の変数だけ表示したいので、「for i in v1: for j in v2:」で「i」と「j」を回して、「if x[i,j].value() == 1: \(x_{ij}=1\) のものだけ取り出しています。


最後に最適値を表示するように設定します。最適値は「object.value()」で得ることができます。


結果を1つずつ解釈する


上のコードを実行したら以下の結果が出力されたので、この結果を1つずつ解釈していきましょう。


1行目は結果の状態を表しています。Optimalと表示されているのでちゃんと最適解が得られていることが分かりますね。

2~5行目では最適解(変数の値が1のものだけ)を表しています。これを見るとx02、x10、x21、x33が1であることが分かります。

これはつまり辺\((1,3)\)、辺\((2,1)\)、辺\((3,2)\)、辺\((4,4)\)を選ぶのが最適な割当だと言うことを表しています。

pythonは数字が0からスタートするので数字が1つずつずれます。

x02 → 辺 \((1,3)\)
x10 → 辺 \((2,1)\)
x21 → 辺 \((3,2)\)
x33 → 辺 \((4,4)\)



6行目は最適値を表しています。これを見ると今回の問題の最適値は7であることが分かりますね。


おわりに


いかがでしたか。

今回の記事では割当問題について解説していきました。

今後もこのような組合せ最適化に関する記事を書いていきます!

最後までこの記事を読んでくれてありがとうございました。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

CAPTCHA