割当問題の制約条件を色々変えてみてpythonで解いてみた 1


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

割当問題を簡単に説明すると、例えば10人の従業員を10個の仕事に割り当てるときにどのように割り当てたら効率的になるかを考える問題です。


今回の記事では割当問題の中でも

「従業員に余りが出る場合の割当問題」

をpythonで解いてみたいと思います。それではやっていきましょう!

「従業員に余りが出る場合の割当問題」




「従業員が複数の仕事を担当できる場合の割当問題」




「時間帯によって異なる仕事を行う場合の割当問題」



「1日でやる仕事数に上限がある場合の割当問題」



「最も負担がかかる従業員の負担を最小にする割当問題」


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



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

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


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


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

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

引用元 : 経営工学 – Wikipedia

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



具体例を使って考える



今回は4人の従業員を3つの仕事に割り当てることを考えます。このとき各従業員は高々1個の仕事が割り当てられ、各仕事は必ず1人の従業員が割り当てられます。

従業員全員分の仕事がないので従業員の方は高々1個となります。


従業員と仕事を結ぶ線の上に数字が書いてありますが、これはその従業員がその仕事をどれくらいやりたくないかを表しています。


今考えている問題は、このやりたくない度の合計が最小となるような割当を求めることです。この問題をpythonで解いてみましょう。


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


この割当問題を整数計画問題として定式化したものを、pythonで実装してみたいと思います。前回の記事で上の問題を整数計画問題として定式化する方法は解説しているので、ここでは結果だけ載せておきます。

・パラメータ
\(E = \{1,2,3,4\}\) : 従業員の集合
\(J = \{1,2,3\}\) : 仕事の集合
\(w_{ij}\) : 辺\((i,j)\)の重み


・変数
\(x_{ij} \in \{0,1\} \;\; (\forall i \in E, \forall j \in J)\)

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

\(\min \;\;\; \sum\limits_{i \in E}\sum\limits_{j \in J} w_{ij}x_{ij}\)
\(\;\text{s.t.}\;\;\;\sum\limits_{j \in J} x_{ij} \leq 1 \;\;\;\; (\forall i \in E)\)
\(\;\;\;\;\;\;\;\;\;\sum\limits_{i \in E} x_{ij}=1 \;\;\;\; (\forall j \in J)\)
\(\;\;\;\;\;\;\;\;\; x_{ij} \in \{0,1\} \;\;(\forall i \in E, \forall j \in J)\)


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


「従業員に余りが出る場合の割当問題」を整数計画問題として定式化する記事はこちらから!



コードの全貌と実行結果


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

## 事前準備
!pip install pulp # pulpをインストール
from pulp import * # pulpをインポート

## パラメータの設定
E = [i for i in range(4)] # 従業員側の頂点を設定
J = [j for j in range(3)] # 仕事側の頂点を設定
w = [[2,3,4],[6,8,3],[4,2,5],[1,7,4]] # 各辺の重みを設定

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

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

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


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


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


事前準備

## 事前準備
!pip install pulp # pulpをインストール
from pulp import * # pulpをインポート

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


パラメータの設定

## パラメータの設定
E = [i for i in range(4)] # 従業員側の頂点を設定
J = [j for j in range(3)] # 仕事側の頂点を設定
w = [[2,3,4],[6,8,3],[4,2,5],[1,7,4]] # 各辺の重みを設定

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


Eが従業員側の頂点Jが仕事側の頂点を表しています。[i for i in range(4)]とすることで[0,1,2,3]を手っ取り早く作成することができます。range()の括弧の中の数字を変えることで、リストの要素数を変えることができます。

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


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


整数計画問題の設定

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


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


問題をMinimizeに設定

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

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

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



0-1変数の設定

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


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


上の例で言うと名前はx、添字の集合は従業員の集合Eと仕事の集合J、変数はBinary(0-1変数)と設定しています。

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



目的関数の設定

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

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

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

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


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


for i in E for j in Jと書くことで\(\sum\limits_{i \in E}\sum\limits_{j \in J}\)を表現することができます。


制約条件の設定

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


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

\(\sum\limits_{j \in J}x_{ij} \leq 1 \;\; (\forall i \in E)\)

を設定します。先ほどと同じように\(\sum\)はlpSumで表現します。かっこの中を(x[i,j] for j in J)とすることで
\(\sum\limits_{j \in J}x_{ij}\)
を表現することができます。

これでが1以下になれば良いので<=1としています。

仕事側の頂点に対しても同じように制約条件を設定しますが、等式なので==1とします。後は一緒です。

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



問題を解く

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


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


結果の表示

## 結果の表示
print(LpStatus[result]) # 得られた結果の状態を表示(Optimalなら最適解が得られている)
for i in E:
  for j in J:
    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\)に対して\(x_{ij}=1\)の変数だけ表示したいので、for i in E: for j in J:でiとjを回して、if x[i,j].value() == 1:で\(x_{ij}=1\)のものだけ取り出しています。


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


結果を1つずつ解釈する


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


1行目は無視して大丈夫です。(pulpが既にインストールされていることを表しています。)

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


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

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

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

x12 → 辺\((2,3)\)
x21 → 辺\((3,2)\)
x30 → 辺\((4,1)\)



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


おわりに


いかがでしたか。

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

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

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

コメントを残す

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

CAPTCHA