飲食店の従業員割り当て問題¶
多くの従業員を擁する小売業やサービス業では、各従業員の役職やスキル、希望勤務地と、日々変動する多種多様な業務を考慮して、各店舗に適切に従業員を割り当てる必要があります。
ここでは、飲食チェーン店を例として、従業員を店舗に割り当てる組合せ最適化問題に取り組みます。特に、役職やスキルの種類やレベル、役割や希望勤務地に応じた適切な割り当てを行い、各店舗における業務の効率化と店舗間・従業員間の業務量の平準化を目指します。
例えば、従業員は次のような属性を持つ場合があります。
役職
- 店長
- 副店長
- 役職なし
役割
- 調理担当
- ホール担当
スキル量
- 調理スキル(複数)
例:ステップ3 で取り扱うすしチェーン店の場合- 捌き
- 握り
- 汁物
- 一品
勤務先店舗の希望度
- 勤務不可
- 勤務可能
- 勤務希望
また、各店舗には下記の要求(制約条件)が課せられているとします。
- 各店舗の要求
- 役職、役割ごとの必要人数
- 必要な調理スキルの種類やそのレベル
以上を考慮した上で、各店舗、各役割に対する要求従業員数の充足率の最大化と分散の最小化を設定し、さらに、従業員割り当ての効率化と店舗間の偏りの平準化を狙います。
一度に全ての条件を考慮するのは複雑なので、次のステップごとに、少しずつ条件を増やしながら定式化を行います。
- ステップ1
役職や役割、スキル量を考慮せずに、各従業員の勤務先店舗の希望度及び各店舗の要求従業員数のみを考慮して、割り当てを実施します。 - ステップ2
ステップ1 の設定条件に加え、各店舗の役職毎の要求人数を満たすような従業員割り当てを実施します。 - ステップ3
ステップ2 の設定条件に加え、各従業員の役割及びスキル量、各店舗の各調理スキル要求を満たすように割り当てを実施します。
それでは上記のステップに沿って、定式化及び実装を行います。
ステップ1¶
ここでは、各従業員の勤務先店舗の希望度及び各店舗の要求従業員数のみを考慮して、各店舗へ従業員を割り当てます。
各店舗には必要な従業員の人数が設定されており、一方で各従業員は、各店舗ごとに勤務希望度を持っているとします。希望度は次のように0~2の整数で表現され、勤務不可・勤務可能・勤務希望と対応します。
- 勤務不可 → 希望度:0
- 勤務可能 → 希望度:1
- 勤務希望 → 希望度:2
そこで、店舗の要求人数満たしつつ、従業員の希望度に沿った店舗への割当を考えます。
1.1. 定式化¶
定式化で用いる集合、定数及び決定変数を定義します。
集合¶
- $W$:従業員集合 (記号 $i \in W$ を用いて従業員を表す)
- $S$:店舗集合 (記号 $l \in S$ を用いて店舗を表す)
定数¶
- $r_{l}$:店舗 $l$ の要求従業員数($l \in S$)
- $c_{i,l}$:従業員 $i$ が店舗 $l$ で勤務する場合の希望度($i \in W$ 及び $l \in S$)
決定変数¶
- $L_{i,l}\in \{0,1\}$:従業員 $i$ を店舗 $l$ に割当てる場合に
1
となり割り当てない場合に0
となる($i\in W$ 及び $l\in S$)
従業員に対してどの店舗に割り当てるのかを最適化します。したがって、決定変数 $L$ は従業員数×店舗数のサイズを有するバイナリ変数として宣言します。
例えば、従業員数が5名で、店舗として「博多店」と「天神店」がある場合、2 x 5 の10バイナリ変数の集合で表現されます。
従業員 $i$ | 博多店 ($l=0$) | 天神店 ($l=1$) |
---|---|---|
$0$ | $L_{0,0}$ | $L_{0,1}$ |
$1$ | $L_{1,0}$ | $L_{1,1}$ |
$2$ | $L_{2,0}$ | $L_{2,1}$ |
$3$ | $L_{3,0}$ | $L_{3,1}$ |
$4$ | $L_{4,0}$ | $L_{4,1}$ |
目的関数¶
割り当ての際に、各店舗が要求する従業員数に対して、割り当てた従業員の充足率を考慮することは重要です。店舗 $l$ の充足率 $w_l$ とは、『店舗 $l$ の要求する従業員数 $r_l$』に対して『店舗 $l$ に割り当てられた従業員数』の比であり、次のように定義できます。
$$ w_l = \frac{1}{r_l} \displaystyle\sum_{i\in W} L_{i,l} $$
つまり、充足率が1以上であれば、要求は満たされており、1未満であれば要求が満たされていない、ということになります。
一般的に、店舗間で従業員数の充足率に偏りが起こることは望ましくありません。そこで次の3つを考慮します。以下で $\langle \cdot \rangle$ は平均操作を示します。
-
充足率 $w_l$ 平均の最大化
$$ \begin{matrix} {\rm maximize} & \langle w_l \rangle \end{matrix} $$ -
充足率 $w_l$ 分散の最小化
$$ \begin{matrix} {\rm minimize} & \langle w_l^2 \rangle - \langle w_l \rangle^2 \end{matrix} $$ -
割当先店舗 $l$ に対する全従業員の勤務希望度 $c_{i,l}$ の最大化
$$ \begin{matrix} {\rm maximize}& \displaystyle \sum_{i\in W}\sum_{l\in S}c_{i,l}L_{i,l} \end{matrix} $$
ここで、割り当てられていない($L_{i,l}=0$)店舗に対する従業員 $i$ の勤務希望度は、$c_{i,l}L_{i,l}=0$ のため、自然と総和に影響しないことに注意してください。
制約条件¶
先ほどの決定変数の定義では、ある従業員が同時に複数の店舗に割り当てられる状況が発生し得ます。このような割り当てを回避するために、同じ従業員の複数店舗への割り当てを禁止する、次のような制約を与えます。
- 従業員 $i$ は同時に1店舗にのみ割り当てられる
$$ \sum_{l \in S}L_{i,l} = 1 \:\: (\forall i\in W) $$
また、店舗毎に要求従業員数 $r_l$ が設定されており、各店舗に割り当てられる従業員の総数はこれ以上でなければいけないため、さらに次の制約を与えます。
-
各店舗 $l$ には、その店舗の要求従業員数 $r_l$ 以上の従業員を割り当てる
$$ \sum_{i\in W}L_{i,l} \geq r_{l} \:\: (\forall l \in S) $$
1.2. データ作成¶
それでは、実際に割り当て問題を実行するための条件設定を行います。例として、従業員数5名、店舗数2のデータを次のように作成します。
データの格納には pandas.DataFrame
を使用します。
import pandas as pd
# 各店舗の名前と要求人数情報の設定
dict_req = {"location": ["tenjin", "hakata"], "num_employees": [2, 3]}
# 各従業員の勤務先希望情報の設定
dict_worker_loc = {
"worker_id": [0, 1, 2, 3, 4], # 従業員の ID
"tenjin": [2, 2, 1, 0, 1], # 各従業員の tenjin 店舗で働きたい希望度
"hakata": [1, 1, 1, 1, 0], # 各従業員の hakata 店舗で働きたい希望度
}
df_req = pd.DataFrame.from_dict(dict_req, orient="index").T
print("各店舗の要求従業員数")
display(df_req)
df_worker_loc = pd.DataFrame.from_dict(dict_worker_loc, orient="index").T
print("\n各従業員の勤務希望度")
display(df_worker_loc)
workers = df_worker_loc["worker_id"].values
locations = df_req["location"].values
# 各データのサイズを取得
num_workers = len(workers)
num_locations = len(locations)
# 店舗名とそのインデックスをそれぞれkeyとvalueとする辞書の作成
loc2idx = dict((v, i) for i, v in enumerate(df_req["location"].values))
# 店舗インデックスと店舗名をそれぞれkeyとvalueとする辞書の作成
idx2loc = dict((i, v) for i, v in enumerate(df_req["location"].values))
1.3. Amplify による実装¶
それでは Amplify を用いて実装します。
最初に決定変数 $L$ を表すバイナリ変数 location_variables
を VariableGenerator
クラスで作成します。
from amplify import VariableGenerator
gen = VariableGenerator()
# 従業員 i が店舗 l に割り当てられるか否かを表すバイナリ変数
location_variables = gen.array("Binary", num_workers, num_locations)
# 決定変数は5x2の行列となっている。
print(location_variables)
希望度に示される勤務不可店舗に関しては勤務しないことが予め分かっている為、location_variables
の当該の要素に定数 0
を与えます。これにより最終的に求解の対象となる問題サイズの削減が可能です。
from itertools import product
# 勤務不可店舗に関しては、0を予め代入
for i, l in product(range(num_workers), locations):
worker_req = df_worker_loc.iloc[i][l]
# 希望度から勤務不可
if worker_req == 0:
location_variables[i, loc2idx[l]] = 0
## 決定変数の中で、勤務不可店舗に関する要素が0に固定されている
print(location_variables)
次に各店舗 $l$ の要求人数に対する充足率 $w_l$ の計算を実装します。充足率 $w_l$ は、1.1. 定式化 での説明の通り、次のように表されます。
$$ \begin{align*} w_l = \frac{1}{r_l} \displaystyle\sum_{i\in W} L_{i,l} \end{align*} $$
w_l = location_variables.sum(axis=0) / df_req["num_employees"].values
また、1.1. 定式化 での導入した目的関数の各項目を計算します。最大化する関数についてはその負値を考慮することで最小化問題に変換します。
-
充足率 $w_l$ 平均の最大化
→ 充足率 $w_l$ 平均の負値の最小化に変換
$$ \begin{matrix} {\rm minimize} & -\langle w_l \rangle \end{matrix} $$
-
充足率 $w_l$ 分散の最小化
$$ \begin{matrix} {\rm minimize} & \langle w_l^2 \rangle - \langle w_l \rangle^2 \end{matrix} $$
-
割当先店舗 $l$ に対する全従業員の勤務希望度 $c_{i,l}$ の最大化
→ 割当先店舗 $l$ に対する全従業員の勤務希望度 $c_{i,l}$ の負値の最小化に変換
$$ \begin{matrix} {\rm minimize}& \displaystyle -\sum_{i\in W}\sum_{l\in S}c_{i,l}L_{i,l} \end{matrix} $$
from amplify import sum
# 充足率の平均の最大化(充足率の負値を最小化)
average_fill_rate_cost = -((w_l.sum() / w_l.size) ** 2)
# 充足率分散の最小化
variance_fill_rate_cost = (w_l * w_l).sum() / w_l.size - (w_l.sum() / w_l.size) ** 2
# 従業員の希望度最大化(従業員の希望度の負値を最小化)
location_cost = -sum(
num_workers,
lambda i: sum(
num_locations,
lambda l: df_worker_loc.loc[i][idx2loc[l]] * location_variables[i, l],
),
)
最後に、1.1. 定式化 で紹介した制約条件を実装します。
ある従業員 $i$ は、同時に1店舗にのみ割り当てられる
$$ \begin{align*} \forall i\in W, \sum_{l \in S}L_{i,l} = 1 \end{align*} $$
ある店舗 $l$ には、その店舗の要求従業員数 $r_l$ 以上の従業員を割り当てる
$$ \begin{align*} \forall l \in S, \sum_{i\in W}L_{i,l} \geq r_{l} \end{align*} $$
一つ目の制約条件は one_hot
を用いて記述します (等式制約なので equal_to
を用いて記述することもできます)。二つ目の制約式は不等式制約なので
greater_equal
を用いて記述します。
from amplify import equal_to, greater_equal, one_hot
# ある従業員 i は、同時に1店舗にのみ割り当てられる
location_constarints = sum(one_hot(location_variables[i]) for i in range(num_workers))
# ある店舗 l には、その店舗の要求従業員数 r_l 以上の従業員を割り当てる
require_constraints = sum(
greater_equal(location_variables[:, l], df_req["num_employees"][l])
for l in range(num_locations)
)
先ほど実装した3つの目的関数と2つの制約式を足し合わせ、最適化対象のモデルを作成します。
ここで、注意点として、これらの目的関数、制約式は取りうる値の範囲が異なります。この時、そのまま足し合わせたモデルに対し最適化を行うと、取りうる値が比較的小さな目的関数・制約式が最適化においてあまり考慮されず、結果として、取りうる値が大きな目的関数・制約式のみ満足するような求解が行われる可能性があります。言い換えると、優先度の高い目的関数に対し、意図的に大きな値を取る様にすれば、その目的関数に対して最適化される可能性が高くなります。
従って、全ての目的関数、制約式の取りうる値の範囲がおよそ同じになる様に、または、優先的に最適化させたい目的関数が相対的に大きくなるように、それぞれに係数(重み)を乗ずる必要があります。下記の
loc_priority
、ave_fill_priority
、var_fill_priority
はそれぞれ、勤務地希望度、充足率平均、充足率分散に対応する係数です。また、constraint_weight
は制約式に対する重みです。
この時、それぞれの目的関数がどの程度の値になるのかを考慮して係数を決定します。例えば分散は正の小さい値になるので、目的関数として考慮させるには大きな係数を与える必要があるでしょう。
# それぞれの目的関数の係数
loc_priority = 1
ave_fill_priority = 1
var_fill_priority = 10
# 目的関数
cost_func = (
loc_priority * location_cost
+ ave_fill_priority * average_fill_rate_cost
+ var_fill_priority * variance_fill_rate_cost
)
制約条件についても適切に強さを設定する必要があります。制約条件は目的関数に対するペナルティ関数としてイジングマシンに与えられるため、目的関数の取り得る値より少々大きめの値を推定して決定します。ここでは $10$ 程度の値を与えます。求解の結果、制約条件を満たす解が見つからなければ、制約条件に対する重みを少し大きくして、再度求解を試みます。逆に、制約条件に対する重みを極端に大きくした場合、最適化過程において、目的関数が相対的に考慮されなくなりますので、注意が必要です。
# 制約条件を表すペナルティ関数の重み
constraint_weight = 10
# 制約条件
constraints = constraint_weight * (location_constarints + require_constraints)
# 目的関数と制約条件を足し合わせ、最適化対象のモデルを作成
model = cost_func + constraints
以上で定式化は完了です。
from amplify import FixstarsClient
from datetime import timedelta
# クライアントの設定
client = FixstarsClient()
client.parameters.timeout = timedelta(milliseconds=1000) # タイムアウトは 1000 ms
# client.token = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" # ローカル環境等で使用する場合は、Fixstars Amplify AE のアクセストークンを入力してください。
次に、設定したクライアントからソルバーを作成し、定式化したモデルを最適化します。求解後、決定変数行列 location_variables
に対応した形式で解を取得するため、evaluate
メソッドを用いて解を取り出します。取得した解を location_solutions
とします。
from amplify import solve
# 求解し、結果を取得
result = solve(model, client)
# 制約条件を満たす解が得られなかった場合は、RuntimeError を出す(重みなどを調整し、再求解)。
if len(result) == 0:
raise RuntimeError("The given constraints are not satisfied")
values = result.best.values
# 変数行列 `location_variables` に対応した形式で解を取り出し
location_solutions = location_variables.evaluate(values)
print(location_solutions)
結果の確認¶
求解結果から従業員がそれぞれがどこの店舗で勤務を行うのか出力します。 求解結果 location_solutions[i][l] = 1
であれば従業員 $i$ は店舗 $l$
割り当てられたことを表します。したがって、解が$1$であるインデックスを取り出すことで、どの従業員がどの店舗で勤務するのかを取得できます。
表データで出力するために、pandas.DataFrame
に結果を格納します。
import numpy as np
from collections import defaultdict
location_index_list = np.where(np.array(location_solutions) == 1)[1]
dict_df = defaultdict(list)
for i, loc_ind in enumerate(location_index_list):
worker_id = df_worker_loc.loc[i]["worker_id"]
## 割り当て先店舗
loc = locations[loc_ind]
dict_df["worker_id"].append(worker_id)
dict_df["allocation"].append(loc)
df_result = pd.DataFrame.from_dict(dict_df, orient="index").T
print("従業員ごとの割り当て先店舗")
display(df_result)
最後にどの程度店舗の要求人数を満たしているか確認します。1.1. 定式化 での説明の通り、求解結果に基づき充足率を計算します。
# df_result 内の `allocation` リストにおいて、各店舗名が現れる回数をカウント = 各店舗に割り当てられた従業員数
num_employees_allocated = df_result["allocation"].value_counts()
fill_rate = df_req.copy()
fill_rate["fill rate"] = [
df_req.loc[l]["num_employees"] / num_employees_allocated[idx2loc[l]]
for l in range(num_locations)
]
print("店舗ごとの充足率")
display(fill_rate)
全ての店舗において、充足率が1.0以上であるので、ステップ1において、従業員割り当てが上手く行われたことが確認できました。
2.1. 定式化¶
まず、定式化で用いる集合、定数及び決定変数を定義します。
集合¶
- $W$:従業員集合 (記号 $i \in W$ を用いて従業員を表す)
- $S$:店舗集合 (記号 $l \in S$ を用いて店舗を表す)
- $R$: 役職集合 (記号 $j \in R$ を用いて役職を表す)
- $j=0$: 店長 (
manager
) - $j=1$: 副店長 (
submanager
) - $j=2$: 役職なし (
staff
)
- $j=0$: 店長 (
定数¶
- $r_{j,l}$:店舗 $l$ における役職 $j$ の要求従業員数($l \in S$ 及び $j \in R$)
- $c_{i,l}$:従業員 $i$ が店舗 $l$ で勤務する場合の希望度($i \in W$ 及び $l \in S$)
- $m_{i,j}$: 従業員 $i$ を役職 $j$ に割り当て可能か、0:不可能, 1:可能($i \in W$ 及び $j \in R$)
決定変数¶
- $M_{i,j,l}$:従業員 $i$ を役職 $j$ として、店舗 $l$ に割り当てる
1
か否0
か($i\in W$ 及び $j\in R$、$l\in S$ - $L_{i,l}\in \{0,1\}$:従業員 $i$ を店舗 $l$ に割当てる
1
か否0
か($i\in W$ 及び $l\in S$)- $L_{i,l} = \sum_{j \in R} M_{i,j,l}$ の関係がある
目的関数¶
ステップ1と同様に、各店舗における役職を考慮しない場合の要求従業員数に対する割り当て従業員の充足率 $w_l$ を定義します。
$$ w_l = \frac{\displaystyle \sum_{i\in W} L_{i,l}}{\displaystyle \sum_{j\in R} r_{j,l}} $$
ステップ1と同様に、下記の3つを目的関数とします。
-
充足率 $w_l$ 平均の最大化
$$ \begin{matrix} {\rm maximize} & \langle w_l \rangle \end{matrix} $$ -
充足率 $w_l$ 分散の最小化
$$ \begin{matrix} {\rm minimize} & \langle w_l^2 \rangle - \langle w_l \rangle^2 \end{matrix} $$ -
割当先店舗 $l$ に対する全従業員の勤務希望度 $c_{i,l}$ の最大化
$$ \begin{matrix} {\rm maximize}& \displaystyle \sum_{i\in W}\sum_{l\in S}c_{i,l}L_{i,l} \end{matrix} $$
制約条件¶
ステップ1 と同様に、先ほどの決定変数の定義では、ある従業員が同時に複数の店舗に割り当てられる状況が発生し得ます。また、役職あり(店長・副店長)を適切に割り当てる必要もあります。従って、各店舗に要求される役職ありの従業員を適切に割り当て、さらに、役職有り無しに関わらず、同じ従業員の複数店舗への割り当てを禁止する、次のような制約を与えます。
- ある従業員 $i$ は、同時に1店舗にのみ割り当てられる
$$ \sum_{l \in S}L_{i,l} = 1 \:\:\:\: (\forall i\in W) $$
- 各店舗 $l$ が要求する人数に等しい役職あり従業員 $j \in \left\{0, 1 \right\}$ が割り当てられる
$$ \sum_{i\in W}M_{i,j,l} = r_{j,l} \:\:\:\: (\forall l \in S, \forall j \in \left\{0, 1 \right\}) $$
- 各店舗 $l$ には、その店舗の要求従業員数 $r_l$ 以上の従業員を割り当てる
$$ \sum_{i\in W} L_{i,l} \geq \sum_{j\in R}r_{j,l} \:\:\:\: (\forall l \in S) $$
import pandas as pd
# 各店舗の要求人数情報の設定
dict_req = {
"location": ["tenjin", "hakata", "akasaka", "gakken"], # 店舗名
"num_managers": [1, 1, 1, 1], # 各店舗の店長役職を有する要求従業員数
"num_submanagers": [1, 0, 1, 1], # 各店舗の副店長役職を有する要求従業員数
"num_employees_any_role": [
2,
2,
2,
2,
], # 各店舗の要求従業員数(役職有り無しを問わず全員)
}
df_req = pd.DataFrame.from_dict(dict_req, orient="index").T
# 各従業員の勤務店舗希望情報の設定
dict_worker_loc = {
"worker_id": [0, 1, 2, 3, 4, 5, 6, 7, 8], # 従業員の ID
"tenjin": [2, 0, 0, 0, 1, 1, 2, 1, 1], # 各従業員の tenjin 店舗への配置の希望度
"hakata": [1, 0, 0, 2, 2, 2, 1, 2, 1], # 各従業員の hakata 店舗への配置の希望度
"akasaka": [1, 0, 0, 1, 0, 1, 1, 1, 2], # 各従業員の akasaka 店舗への配置の希望度
"gakken": [1, 2, 2, 0, 0, 0, 0, 0, 0], # 各従業員の gakken 店舗への配置の希望度
}
df_worker_loc = pd.DataFrame.from_dict(dict_worker_loc, orient="index").T
# 各従業員の役職資格情報の設定
dict_worker_skill = {
"worker_id": [0, 1, 2, 3, 4, 5, 6, 7, 8], # 従業員の ID
"manager": [1, 1, 0, 0, 1, 1, 1, 0, 1], # 店長の資格がある 1 か否 0 か
"submanager": [1, 1, 1, 0, 1, 1, 1, 0, 1], # 副店長の資格がある 1 か否 0 か
"staff": [1, 1, 1, 1, 1, 1, 1, 1, 1], # 役職無し従業員の資格がある 1 か否 0 か
}
df_worker_skill = pd.DataFrame.from_dict(dict_worker_skill, orient="index").T
各店舗の店長 (manager)、副店長 (submanager)、全従業員 (manager + submanager + staff) に関する要求人数が df_req
に格納されています。割り当てられる役職無し従業員 (staff)
の数は、num_employees_any_role - num_managers - num_submanagers
となります。
print("店舗ごとの役職別の要求人数")
display(df_req)
各従業員の各店舗に対する勤務希望度は df_worker_loc
に格納されています。
print("従業員ごとの各店舗に対する勤務希望度")
display(df_worker_loc)
df_worker_skill
には、各従業員の役職資格情報を格納しています。もし値が $1$ ならその役職が担当可能であることを表します。
例えば、worker_id = 1
の従業員は店長と副店長が担当可能であることがわかります。一方 worker_id = 7
の従業員は管理職(店長又は副店長)を担当することができません。
print("従業員ごとの役職別の資格情報")
display(df_worker_skill)
次のように従業員 ID、店舗名、役職名とインデックスの対応関係を設定します。
# 従業員 ID、店舗名、役職名の取得
workers = df_worker_loc["worker_id"].values
locations = df_req["location"].values
roles = ["manager", "submanager", "staff"]
# 店舗インデックスと店舗名をそれぞれkeyとvalueとする辞書の作成
idx2loc = {i: v for i, v in enumerate(locations)}
# 店舗名とそのインデックスをそれぞれkeyとvalueとする辞書の作成
loc2idx = {v: i for i, v in enumerate(locations)}
# 役職インデックスと役職名をそれぞれkeyとvalueとする辞書の作成
idx2role = {i: v for i, v in enumerate(roles)}
# 役職名とそのインデックスをそれぞれkeyとvalueとする辞書の作成
role2idx = {v: i for i, v in enumerate(roles)}
# 各データサイズを取得
num_workers = len(workers)
num_locations = len(locations)
num_roles = len(roles)
# 従業員 i が役職 j で店舗 l に割り当てられる 1 か否 0 かを表すバイナリ変数
role_variables = VariableGenerator().array(
"Binary", num_workers, num_roles, num_locations
)
# 決定変数は9x3x4の行列となっている。
print(role_variables)
ステップ1 と同様に、希望度に示される勤務不可店舗に関しては勤務しないことが予め分かっている為、決定変数の当該要素をゼロ埋めします。また、役職的にも割り当て不可な要素にもゼロ埋めを行います。これにより最終的に求解の対象となる問題サイズの削減が可能です。
for i, l in product(range(num_workers), locations):
worker_req = df_worker_loc.iloc[i][l]
if worker_req == 0:
# 希望度から、該当する店舗 l において、全ての役職で割り当て不可
role_variables[i, :, loc2idx[l]] = 0
for i, j in product(range(num_workers), roles):
worker_skill = df_worker_skill.iloc[i][j]
if worker_skill == 0:
# 従業員 i の役職資格情報から、全ての店舗で当該の役職に対する割り当て不可
role_variables[i, role2idx[j], :] = 0
# 勤務不可能店舗・役職に関する決定変数要素が0に固定されていることを確認
print(role_variables)
決定変数 $L$ を表す location_variables
については、決定変数 $M$ と $L$ の関係から次のようにして得られます(2.1. 定式化 参照)。
location_variables = role_variables.sum(axis=1)
display(location_variables)
次に各店舗 $l$ の要求人数に対する充足率 $w_l$ の計算を実装します。充足率 $w_l$ は、2.1. 定式化 での説明の通り、次のように表されます。
$$ w_l = \frac{\displaystyle \sum_{i\in W} L_{i,l}}{\displaystyle \sum_{j\in R} r_{j,l}} $$
ここで、分母の $\sum_{j\in R} r_{j,l}$ は、各店舗 $l$ における役職を問わない全従業員数であり、これは
df_req["num_employees_any_role"]
です。
# 充足率の計算
w_l = location_variables.sum(axis=0) / df_req["num_employees_any_role"].values
次に、2.1. 定式化 での導入した目的関数の各項目を計算します。最大化する関数についてはその負値を考慮することで最小化問題に変換します。
-
充足率 $w_l$ 平均の最大化
→ 充足率 $w_l$ 平均の負値の最小化に変換
$$ \begin{matrix} {\rm minimize} & -\langle w_l \rangle \end{matrix} $$
-
充足率 $w_l$ 分散の最小化
$$ \begin{matrix} {\rm minimize} & \langle w_l^2 \rangle - \langle w_l \rangle^2 \end{matrix} $$
-
割当先店舗 $l$ に対する全従業員の勤務希望度 $c_{i,l}$ の最大化
→ 割当先店舗 $l$ に対する全従業員の勤務希望度 $c_{i,l}$ の負値の最小化に変換
$$ \begin{matrix} {\rm minimize}& \displaystyle -\sum_{i\in W}\sum_{l\in S}c_{i,l}L_{i,l} \end{matrix} $$
# 充足率の平均の最大化(充足率の負値を最小化)
average_fill_rate_cost = -((w_l.sum() / w_l.size) ** 2)
# 充足率分散の最小化
variance_fill_rate_cost = (w_l * w_l).sum() / w_l.size - (w_l.sum() / w_l.size) ** 2
# 従業員の希望度最大化(従業員の希望度の負値を最小化)
location_cost = -sum(
num_workers,
lambda i: sum(
num_locations,
lambda l: df_worker_loc.loc[i][idx2loc[l]] * location_variables[i][l],
),
)
最後に、2.1. 定式化 で定義した次の制約条件を実装します。
- ある従業員 $i$ は、同時に1店舗にのみ割り当てられる
$$ \sum_{l \in S}L_{i,l} = 1 \:\:\:\: (\forall i\in W) $$
- 各店舗 $l$ が要求する人数の役職あり従業員 $j \in \left\{0, 1 \right\}$ を割り当てる
$$ \sum_{i\in W}M_{i,j,l} = r_{j,l} \:\:\:\: (\forall l \in S, \forall j \in \left\{0, 1 \right\}) $$
- 各店舗 $l$ には、その店舗の要求従業員数 $r_l$ 以上の従業員(役職有り無しに関わらない全従業員の総数)を割り当てる
$$ \sum_{i\in W} L_{i,l} \geq \sum_{j\in R}r_{j,l} \:\:\:\: (\forall l \in S) $$
一つ目と二つ目の制約条件は one_hot
を用いて記述します(あるいは等式制約なので equal_to
を用いて記述することもできます)。三つ目の制約式は不等式制約なので greater_equal
を用いて記述します。
# ある従業員 i は、同時に1店舗にのみ割り当てられる
location_constarints = sum(one_hot(location_variables[i]) for i in range(num_workers))
# 各店舗 l が要求する人数の役職あり従業員 j を割り当てる
req_manager_constraints = sum(
equal_to(role_variables[:, 0, l], df_req["num_managers"][l])
for l in range(num_locations)
)
req_submanager_constraints = sum(
equal_to(role_variables[:, 1, l], df_req["num_submanagers"][l])
for l in range(num_locations)
)
# 各店舗 l には、その店舗の要求従業員数 r_l 以上の従業員(役職有り無しに関わらない全従業員の総数)を割り当てる
req_employee_constraints = sum(
greater_equal(location_variables[:, l], df_req["num_employees_any_role"][l])
for l in range(num_locations)
)
目的関数と制約式から最適化モデルを作成します。目的関数や制約条件に対して重みを設定する必要はありますが、基本的な考え方は、1.3. Amplify による実装 に説明した通りで、以下でも同様の重みを与えます。
# それぞれの目的関数の係数
loc_priority = 1
ave_fill_priority = 1
var_fill_priority = 10
# 目的関数
cost_func = (
loc_priority * location_cost
+ ave_fill_priority * average_fill_rate_cost
+ var_fill_priority * variance_fill_rate_cost
)
# 制約条件を表すペナルティ関数の重み
constraint_weight = 10
# 制約条件
constraints = constraint_weight * (
location_constarints
+ req_manager_constraints
+ req_submanager_constraints
+ req_employee_constraints
)
# 目的関数と制約条件を足し合わせ、最適化対象のモデルを作成
model = cost_func + constraints
以上でステップ2の定式化に関する実装は完了です。
# モデルをソルバーに渡して求解し、結果を取得
result = solve(model, client)
# 制約条件を満たす解が得られなかった場合は、RuntimeError を出す(重みなどを調整し、再求解)。
if len(result) == 0:
raise RuntimeError("The given constraints are not satisfied")
values = result.best.values
# 変数行列 `location_variables` に対応した形式で解を取り出し
location_solutions = location_variables.evaluate(values)
# 変数行列 `role_variables` に対応した形式で解を取り出し
role_solutions = role_variables.evaluate(values)
結果の確認¶
結果から従業員がそれぞれがどこの店舗で勤務を行うのかを出力します。 変数 role_solutions
において、role_solutions[i][j][l] = 1
であれば、従業員 $i$ は役職 $j$ として店舗 $l$
で勤務することを表します。したがって、解が$1$であるインデックスを取り出すことで、どの従業員がどの店舗に、どの役職で割り当てられたのかを取得できます。
import numpy as np
from collections import defaultdict
# role_solutions=1 である、役職及び店舗のインデックスを取得
(role_index_list, loc_index_list) = np.where(np.array(role_solutions) == 1)[1:]
dict_df = defaultdict(list)
for i, (j, l) in enumerate(zip(role_index_list, loc_index_list)):
worker_id = df_worker_loc.loc[i]["worker_id"]
## 割り当てられた役職
role = roles[j]
## 割り当て先店舗
loc = locations[l]
dict_df["worker_id"].append(worker_id)
dict_df["role"].append(role)
dict_df["location"].append(loc)
df_result = pd.DataFrame.from_dict(dict_df, orient="index").T
print("従業員ごとの店舗と役職の割当て")
display(df_result)
最後に、各役職に対する充足率 (fill rate) を出力することで、各店舗の役職毎に要求人数を満たしているかを確認します。ここで、表のセルの成分が N/A
となっている箇所は、その店舗におけるその役職の要求人数が0人であることを表します。
dict_result_alloc = defaultdict(lambda: defaultdict(int))
for loc, role in product(locations, roles):
dict_result_alloc[loc][role] = 0
for i in range(len(df_result)):
data = df_result.loc[i]
role = data["role"]
location = data["location"]
dict_result_alloc[location][role] += 1
df_result_alloc = pd.DataFrame.from_dict(dict_result_alloc, orient="index")
print("店舗ごとの役職別の割り当て人数")
display(df_result_alloc)
# 各店舗の要求人数情報のラベル ['num_managers' 'num_submanagers' 'num_employees_any_role']
num_roles_labels = df_req.columns.values[1 : 1 + num_roles]
dict_result_fill_rate = defaultdict(defaultdict)
# 各店舗ごとに充足率を計算
for l in range(len(df_result_alloc)):
data = df_result_alloc.iloc[l] # 店舗 l の df_result_alloc
loc = data.name # 店舗名
num_req_non_staff = 0 # 各店舗、役職有り従業員の要求数を格納する変数
# 各役職ごとに充足率を計算。
for j in range(len(roles)):
# 各店舗・各役職ごとの要求従業員数
num_required = df_req[df_req["location"] == loc][num_roles_labels[j]].item()
# 各店舗の要求従業員数 df_req の最後の要素は、役職有り無しを問わず全ての従業員数なので、役職無し従業員数は、全ての従業員数から役職有り従業員数 num_req_non_staff を減算。
if j == len(roles) - 1:
num_required -= num_req_non_staff
else:
num_req_non_staff += num_required
# 各店舗・各役職ごとの割り当て従業員数
num_allocated = data[roles[j]].item()
# 充足率を計算。要求従業員数がゼロの場合、N/Aを代入
if num_required > 0:
dict_result_fill_rate[loc][f"{roles[j]} (fill rate)"] = (
num_allocated / num_required
)
else:
dict_result_fill_rate[loc][f"{roles[j]} (fill rate)"] = "N/A"
df_result_fill_rate = pd.DataFrame.from_dict(dict_result_fill_rate, orient="index")
print("店舗ごとの役職別の充足率")
display(df_result_fill_rate)
全店舗において、各役職に対する要求従業員数を満たしいることがわかります。
ステップ3¶
ステップ3では、ステップ2で考慮した、『各従業員の勤務先店舗の希望度』及び『各店舗の要求従業員数』、『各店舗の役職毎の要求人数』に加え、各従業員の役割及び各調理スキルに対するスキル量に基づき、各店舗の各調理スキル要求を満たすように割り当てを実施します。
ここでは、寿司チェーン店を想定します。また、各従業員は「捌き・握り・汁物・一品」といった調理スキルを持ち、それぞれのスキルの高さをスキル量という指標で定量化されています。一方、各店舗にも要求されるスキル量があり、これを満たすように従業員の割り当てを行います。
例えば、「捌き」スキル値の要求量が10である店舗では、その店舗に割り当てられた全従業員の「捌き」調理スキル量の総和が10以上である必要がある、という制約を考慮しなければなりません。
ステップ2までは店舗割当人数の割合を充足率としていましたが、ステップ3では各調理スキル要求に対するスキル量に対する充足率を最適化します。
さらに、従業員には「ホール担当」「キッチン担当」のどちらかの役割を持たせます。ホールに割り当てる人数は与えられるものとし、また、調理スキル値が0の従業員は自動的にホール専任とします。
3.1. 定式化¶
定式化に用いる変数と記号を定義し直します。
集合¶
- $W$:従業員集合 (記号 $i \in W$ を用いて従業員を表す)
- $S$:店舗集合 (記号 $l \in S$ を用いて店舗を表す)
- $R$:役職集合 (記号 $j \in R$ を用いて役職を表す)
- $j=0$:店長 (
manager
) - $j=1$:副店長 (
submanager
) - $j=2$:なし (
staff
)
- $j=0$:店長 (
- $K$:調理スキル集合 (記号 $k$ を用いてスキルを表す)
- $k=0$:捌き (
filleting
) - $k=1$:握り (
nigiri
) - $k=2$:汁物 (
soup
) - $k=3$:一品 (
a_la_carte
)
- $k=0$:捌き (
- $A$:役割集合 (記号 $h$ を用いて役割を表す)
- $h=0$:ホール担当 (
floor staff
) - $h=1$:キッチン担当 (
kitchen staff
)
- $h=0$:ホール担当 (
定数¶
- $t_{k,l}$:店舗 $l$ における調理スキル $k$ に対する要求スキル量($l \in S$ 及び $k\in K$)
- $r_{l}$:店舗 $l$ におけるホール担当の要求人数($l \in S$)
- $c_{i,l}$:従業員 $i$ が店舗 $l$ での勤務に対する希望度($i \in W$ 及び $l \in S$)
- $m_{i,j}$:従業員 $i$ を役職 $j$ に割当て可能か、0:不可能、1:可能($i \in W$ 及び $j \in R$)
- $s_{i,k}$:従業員 $i$ の有する調理スキル $k$ に対するスキル量($i \in W$ 及び $k\in K$)
決定変数¶
- $M_{i,j,l}$:従業員 $i$ を役職 $j$ として、店舗 $l$ へ割り当てる
1
か 否0
か($i\in W$、$j\in R$ 及び $l\in S$) - $P_{i,h,l}$:従業員 $i$ を役割 $h$ として、店舗 $l$ へ割り当てる
1
か否0
か($i\in W$、$h\in A$ 及び $l\in S$) - $L_{i,l}$:従業員 $i$ を店舗 $l$ へ割り当てる
1
か否0
か($i\in W$ 及び $l\in S$)- $L_{i,l} = \sum_{j \in R} M_{i,j,l}$ の関係がある
- $L_{i,l} = \sum_{h \in A} P_{i,h,l}$ の関係がある
目的関数¶
まず、これまでのステップと同様に、目的関数の基礎となる充足率を定義します。ステップ3では、店舗 $l$ における調理スキル $k$ の総スキル量に対する充足率 $w_{k,l}$ を次のように考慮します。
$$ w_{k,l} = \frac{1}{t_{k,l}} \displaystyle \sum_{i\in W} s_{i,k} P_{i,1,l} $$
ここで、$s_{i,k} P_{i,1,l}$ は、店舗 $l$ にキッチン担当として割り当てられた全従業員の調理スキル $k$ のスキル量の総和を計算しており、$t_{k,l}$ はそのスキル量に関する要求量です。
また、これまでのステップ同様に下記を目的関数とします。
-
充足率 $w_{k,l}$ 平均の最大化
$$ \begin{matrix} {\rm maximize} & \left< w_{k,l} \right> \end{matrix} $$
-
充足率 $w_{k,l}$ 分散の最小化
$$ \begin{matrix} {\rm minimize} & \left< w_{k,l}^2 \right> - \left< w_{k,l} \right>^2 \end{matrix} $$
-
割当先店舗 $l$ に対する全従業員の勤務希望度 $c_{i,l}$ の最大化
$$ \begin{matrix} {\rm maximize}& \displaystyle \sum_{i\in W}\sum_{l\in S}c_{i,l}L_{i,l} \end{matrix} $$
制約条件¶
基本的な考え方は ステップ2 と同様ですが、役割に関する変数 $P$ と $M$ を関連付ける記述が必要です。
-
ある従業員$i$は同時に1店舗にのみ割り当てられる
$$ \begin{align*} \sum_{l \in S}L_{i,l} = 1 \:\:\:\:(\forall i\in W) \end{align*} $$
-
各店舗 $l$ が要求する人数に等しい役職有り従業員 $j \in \left\{0, 1 \right\}$ が割り当てられる
$$ \forall l \in S, \forall j \in \left\{0, 1 \right\}, \sum_{i\in W}M_{i,j,l} = r_{j,l} $$
-
各店舗 $l$ が要求する人数に等しいホール担当 $h=0$ の従業員が割り当てられる
$$ \begin{align*} \forall l \in S, \sum_{i\in W} P_{i,0,l} = r_{l} \end{align*} $$
-
決定変数 $P$ と $M$ を関連付ける制約条件
-
$M_{i,j,l}$ から計算される、店舗 $l$ へ割り当てられた役職 $j$ を問わない従業員数:
$$ \sum_{j \in R} M_{i,j,l} $$ -
$P_{i,h,l}$ から計算される、店舗 $l$ へ割り当てられた役割 $h$ を問わない従業員数:
$$ \sum_{h \in A} P_{i,h,l} $$
これらが等しくある必要があるので、 $$ \sum_{j \in R} M_{i,j,l} = \sum_{h \in A} P_{i,h,l} \:\:\:\:(\forall i\in W, \forall l \in S) $$
-
# 各店舗の要求人数情報の設定
dict_req = dict(
location=["tenjin", "hakata"], # 店舗名
nun_managers=[1, 1], # 各店舗の店長役職を有する要求従業員数
num_submanagers=[0, 1], # 各店舗の副店長役職を有する要求従業員数
filleting=[1, 1], # 「捌き」調理スキル量
nigiri=[1, 2], # 「握り」調理スキル量
soup=[2, 2], # 「汁物」調理スキル量
a_la_carte=[2, 2], # 「一品」調理スキル量
num_floor_staffs=[1, 1], # ホールスタッフ
)
df_req = pd.DataFrame.from_dict(dict_req, orient="index").T
# 各従業員の勤務店舗希望情報の設定
dict_worker_loc = dict(
worker_id=[0, 1, 2, 50, 43], # 従業員の ID
tenjin=[2, 1, 1, 1, 1], # 各従業員の tenjin 店舗で働きたい希望度
hakata=[1, 2, 1, 1, 1], # 各従業員の hakata 店舗で働きたい希望度
)
df_worker_loc = pd.DataFrame.from_dict(dict_worker_loc, orient="index").T
# 各従業員の役職資格、スキル情報の設定
dict_worker_skill = dict(
worker_id=[0, 1, 2, 50, 43], # 従業員の ID
manager=[1, 1, 0, 0, 0], # 店長の資格がある 1 か否 0 か
submanager=[1, 1, 0, 1, 1], # 副店長の資格がある 1 か否 0 か
staff=[1, 1, 1, 1, 1], # 役職無し従業員の資格がある 1 か否 0 か
filleting=[2, 2, 0, 1, 1], # 捌き調理スキルのスキル量
nigiri=[2, 2, 0, 2, 2], # 握り調理スキルのスキル量
soup=[2, 2, 0, 0, 0], # 汁物調理スキルのスキル量
a_la_carte=[2, 2, 0, 1, 1], # 一品調理スキルのスキル量
)
df_worker_skill = pd.DataFrame.from_dict(dict_worker_skill, orient="index").T
各店舗が要求する店長、副店長人数と、要求調理スキル量、ホール担当人数が df_req
に格納されています。
print("店舗ごとの役職別の要求人数")
display(df_req)
各従業員の各店舗に対する勤務希望度は df_worker_loc
に格納されています。
print("従業員ごとの各店舗に対する勤務希望度")
display(df_worker_loc)
df_worker_skill
には、各従業員の役職資格及び調理スキル情報を格納しています。manager
と
submanager
については、もし値が $1$ ならその役職が担当可能であることを表します。一方、filleting
, nigiri
,
soup
,
a_la_carte
についてはそれぞれの調理スキルレベルを表します。全ての調理スキル量が $0$ の場合はキッチン担当ができない(ホール専任)従業員であることを表します。
print("従業員ごとの役職別のスキル情報")
display(df_worker_skill)
次のように従業員 ID、店舗名、役職名、役割名、調理スキル名とインデックスの対応関係を設定します。
# 従業員 ID、店舗名、役職名、役割名、調理スキル名の取得
workers = df_worker_loc["worker_id"].values
locations = df_req["location"].values
roles = ["manager", "submanager", "staff"]
# 役割名
assigns = ["floor", "kitchen"]
# 調理スキル名
skills = ["filleting", "nigiri", "soup", "a_la_carte"]
# 店舗インデックスと店舗名をそれぞれkeyとvalueとする辞書の作成
idx2loc = dict((i, v) for i, v in enumerate(locations))
# 店舗名とそのインデックスをそれぞれkeyとvalueとする辞書の作成
loc2idx = dict((v, i) for i, v in enumerate(locations))
# 役職インデックスと役職名をそれぞれkeyとvalueとする辞書の作成
idx2role = dict((i, v) for i, v in enumerate(roles))
# 役職名とそのインデックスをそれぞれkeyとvalueとする辞書の作成
role2idx = dict((v, i) for i, v in enumerate(roles))
# 調理スキルインデックスと調理スキル名をそれぞれkeyとvalueとする辞書の作成
idx2skill = dict((i, v) for i, v in enumerate(skills))
# 調理スキル名とそのインデックスをそれぞれkeyとvalueとする辞書の作成
skill2idx = dict((v, i) for i, v in enumerate(skills))
# 各データサイズを取得
num_workers = len(workers)
num_locations = len(locations)
num_roles = len(roles)
num_assigns = len(assigns)
num_skills = len(skills)
3.3. Amplify による実装¶
それでは Amplify を用いて実装します。
最初に変数 $M$ を表す role_variables
と変数 $P$ を表す assign_variables
を
VariableGenerator
で作成します。それぞれ、「従業員数×役職数×店舗数」及び「従業員数×役割数×店舗数」、の3次元配列です。
# 従業員iが役職jで店舗lに勤務することを表す決定変数
gen = VariableGenerator()
role_variables = gen.array("Binary", num_workers, num_roles, num_locations)
# 従業員iが役割hで店舗lに勤務することを表す決定変数
assign_variables = gen.array("Binary", num_workers, num_assigns, num_locations)
これまでと同様に、勤務できない店舗や役職に関しては事前に変数の値を代入しておきます。
for i, l in product(range(num_workers), locations):
worker_req = df_worker_loc.iloc[i][l]
if worker_req == 0:
# 全ての役職で店舗割当が不可
role_variables[i, :, loc2idx[l]] = 0
# 全ての役割で店舗割当が不可
assign_variables[i, :, loc2idx[l]] = 0
for i, j in product(range(num_workers), roles):
worker_skill = df_worker_skill.iloc[i][j]
if worker_skill == 0:
# 全ての店舗で役職が不可
role_variables[i, role2idx[j], :] = 0
for i in range(num_workers):
if all(df_worker_skill.iloc[i][k] == 0 for k in skills):
# 全ての店舗で役割(キッチン担当)が不可
assign_variables[i, 1, :] = 0
# 勤務不可店舗・役職に関する変数が0に固定されている
print(role_variables)
# 担当不可役店舗・役割に関する変数が0に固定されている
print(assign_variables)
変数 $L$ を表す location_variables
については、決定変数 $P$ と$L$ の関係から次のようにして得られます (3.1. 定式化 参照)。
location_variables = assign_variables.sum(axis=1)
display(location_variables)
次に、各店舗、各調理スキルに対する充足率 $w_{k,l}$ を計算します。充足率 $w_{k,l}$ は、3.1. 定式化 での説明の通り、次のように表されます。
$$ \begin{align*} w_{k,l} = \frac{1}{t_{k,l}} \displaystyle \sum_{i\in W} s_{i,k} P_{i,1,l} \end{align*} $$
また、ここでリスト形式の w_kl
を amplify.PolyArray
にキャストしておくと、以降、w_kl.sum()
や
w_kl.size
, w_kl * w_kl
などが使えて便利です。
from amplify import PolyArray
# 各店舗、各調理スキルに対する充足率 w_kl の計算
w_kl = PolyArray(
[
sum(
df_worker_skill[idx2skill[k]] * assign_variables[:, 1, l],
)
/ df_req[idx2skill[k]][l]
for k in range(num_skills)
for l in range(num_locations)
]
)
次に、3.1. 定式化 での導入した目的関数の各項目を計算します。最大化する関数についてはその負値を考慮することで最小化問題に変換します。
-
充足率の平均の最大化
→ 充足率 $w_l$ 平均の負値の最小化に変換
$$ \begin{matrix} {\rm minimize} & -\langle w_{k,l} \rangle \end{matrix} $$
-
充足率の分散の最小化
$$ \begin{matrix} {\rm minimize} & \langle w_{k,l}^2 \rangle - \langle w_{k,l} \rangle^2 \end{matrix} $$
-
割当先店舗 $l$ に対する全従業員の勤務希望度 $c_{i,l}$ の最大化
→ 割当先店舗 $l$ に対する全従業員の勤務希望度 $c_{i,l}$ の負値の最小化に変換
$$ \begin{matrix} {\rm minimize}& \displaystyle -\sum_{i\in W}\sum_{l\in S}c_{i,l}L_{i,l} \end{matrix} $$
# 充足率の平均の最大化(充足率の負値を最小化)
average_fill_rate_cost = -((w_kl.sum() / w_kl.size) ** 2)
# 充足率分散の最小化
variance_fill_rate_cost = (w_kl * w_kl).sum() / w_kl.size - (
w_kl.sum() / w_kl.size
) ** 2
# 従業員の希望度最大化(従業員の希望度の負値を最小化)
location_cost = -sum(
num_workers,
lambda i: sum(
num_locations,
lambda l: df_worker_loc.loc[i][idx2loc[l]] * location_variables[i, l],
),
)
最後に、3.1. 定式化 で定義した次の制約条件を実装します。
ある従業員$i$は同時に1店舗にのみ割り当てられる
$$ \begin{align*} \forall i\in W, \sum_{l \in S}L_{i,l} = 1 \end{align*} $$
各店舗 $l$ が要求する人数に等しい役職有り従業員 $j \in \left\{0, 1 \right\}$ が割り当てられる
$$ \forall l \in S, \forall j \in \left\{0, 1 \right\}, \sum_{i\in W}M_{i,j,l} = r_{j,l} $$
各店舗 $l$ が要求する人数に等しいホール担当 $h=0$ の従業員が割り当てられる
$$ \begin{align*} \forall l \in S, \sum_{i\in W} P_{i,0,l} = r_{l} \end{align*} $$
決定変数 $P$ と $M$ を関連付ける制約条件
$$ \sum_{j \in R} M_{i,j,l} = \sum_{h \in A} P_{i,h,l} \:\:\:\:(\forall i\in W, \forall l \in S) $$
これらの制約は等式制約なので one_hot
又は equal_to
を用います。
# ある従業員 i は同時に1店舗にのみ割り当てられる
location_constarints = sum(one_hot(location_variables[i]) for i in range(num_workers))
# 各店舗 l が要求する人数に等しい役職有り従業員 (j=0 or 1) が割り当てられる
req_manager_constraints = sum(
equal_to(role_variables[:, 0, l], df_req["nun_managers"][l])
for l in range(num_locations)
)
req_submanager_constraints = sum(
equal_to(role_variables[:, 1, l], df_req["num_submanagers"][l])
for l in range(num_locations)
)
# 各店舗 l が要求する人数に等しいホール担当 (h=0) の従業員が割り当てられる
req_hall_constraints = sum(
equal_to(assign_variables[:, 0, l], df_req["num_floor_staffs"][l])
for l in range(num_locations)
)
# 決定変数 P と M を関連付ける制約条件
role_assign_constraints = sum(
equal_to(
(role_variables.sum(axis=1))[i, l] - (assign_variables.sum(axis=1))[i, l], 0
)
for i in range(num_workers)
for l in range(num_locations)
)
目的関数と制約式から最適化モデルを作成します。目的関数や制約条件に対して重みを設定する必要はありますが、基本的な考え方は、1.3. Amplify による実装 に説明した通りで、以下でも同様の重みを与えます。
# それぞれの目的関数の係数
loc_priority = 1
ave_fill_priority = 1
var_fill_priority = 10
# 目的関数
cost_func = (
loc_priority * location_cost
+ ave_fill_priority * average_fill_rate_cost
+ var_fill_priority * variance_fill_rate_cost
)
# 制約条件を表すペナルティ関数の重み
constraint_weight = 10
# 制約条件
constraints = constraint_weight * (
location_constarints
+ req_manager_constraints
+ req_submanager_constraints
+ req_hall_constraints
+ role_assign_constraints
)
# 目的関数と制約条件を足し合わせ、最適化対象のモデルを作成
model = cost_func + constraints
以上で、ステップ3の定式化に関する実装は完了です。
# モデルをソルバーに渡して求解し、結果を取得
result = solve(model, client)
# 制約条件を満たす解が得られなかった場合は、RuntimeError を出す(重みなどを調整し、再求解)。
if len(result) == 0:
raise RuntimeError("The given constraints are not satisfied")
values = result.best.values
# 割り当て店舗に関する決定変数行列 `location_variables` に対応した形式で解を取り出し
location_solutions = location_variables.evaluate(values)
# 割当店舗と役職に関する決定変数行列 `role_variables` に対応した形式で解を取り出し
role_solutions = role_variables.evaluate(values)
# 割当店舗と役割に関する決定変数行列 `assign_variables` に対応した形式で解を取り出し
assign_solutions = assign_variables.evaluate(values)
結果の確認¶
結果から従業員がそれぞれがどこの店舗で勤務を行うのかを出力します。 変数 role_solutions
において、role_solutions[i][j][l] = 1
であれば、従業員 $i$ は役職 $j$ として店舗 $l$
で勤務することを表します。したがって、解が$1$であるインデックスを取り出すことで、どの従業員がどの役職、店舗で勤務するのかを取得できます。
import numpy as np
from collections import defaultdict
(role_index_list, loc_index_list) = np.where(np.array(role_solutions) == 1)[1:]
dict_df = defaultdict(list)
for i, (j, l) in enumerate(zip(role_index_list, loc_index_list)):
## 配属勤務地
worker_id = df_worker_loc.loc[i]["worker_id"]
role = roles[j]
loc = locations[l]
dict_df["worker_id"].append(worker_id)
dict_df["role"].append(role)
dict_df["location"].append(loc)
df_result = pd.DataFrame.from_dict(dict_df, orient="index").T
print("従業員ごとの店舗と役職の割当て")
display(df_result)
次に、店舗ごとに要求スキル量がどの程度満たされているかを出力します。hall
または
kitchen
のどちらに割り当てられたのかをassign_solutions
の結果から取り出し、kitchen
の場合には、その従業員が割り当てられている店舗の調理スキル量に従業員の調理スキル量それぞれを加算していきます。調理スキルに加えて、ホール担当の従業員数についても充足率計算のために加算します。
表の各セルにおいて、合計量 / 要求量 で算出した充足率 (fill rate) を出力しています。
(assign_list, loc_index_list) = np.where(np.array(assign_solutions) == 1)[1:]
dict_result_loc = defaultdict(lambda: defaultdict(int))
for i, (j, l) in enumerate(zip(assign_list, loc_index_list)):
assign = assigns[j]
worker_id = df_worker_loc.loc[i]["worker_id"]
loc = locations[l]
if assign == "kitchen":
# kitchenならば、すべての調理スキルの足し算を行う。
for skill in skills:
dict_result_loc[loc][skill] += df_worker_skill.loc[i][skill]
else:
# ホールスタッフとして割り当てられた従業員数もカウント
dict_result_loc[loc]["floor"] += 1
df_result_loc = pd.DataFrame.from_dict(dict_result_loc, orient="index")
dict_result = defaultdict(defaultdict)
for i in range(len(df_result_loc)):
loc = df_result_loc.iloc[i].name
# すべての調理スキルに対して充足率を計算
for skill in skills:
require_num_skill = df_req[df_req["location"] == loc][skill].item()
satisfy_num_skill = df_result_loc.iloc[i][skill].item()
dict_result[loc][
f"{skill} (fill rate)"
] = f"{satisfy_num_skill/require_num_skill}"
# ホールスタッフの従業員数の充足率を計算
require_num_skill = df_req[df_req["location"] == loc]["num_floor_staffs"].item()
satisfy_num_skill = df_result_loc.iloc[i]["floor"].item()
dict_result[loc][
"floor staffs (fill rate)"
] = f"{satisfy_num_skill/require_num_skill}"
df_result_skills = pd.DataFrame.from_dict(dict_result, orient="index")
print("店舗ごとの要求スキル量に対する充足率")
display(df_result_skills)
各店舗で要求されたスキル量及びホール担当の従業員数が満たされていることがわかります。
また、各店舗の役職毎に、要求人数を満たしているか確認します。各役職に対する充足率も計算します。ここで、表のセルの成分が N/A
となっている箇所は、その店舗におけるその役職の要求人数が0人であることを表します。
dict_result_alloc = defaultdict(lambda: defaultdict(int))
for loc, role in product(locations, roles):
dict_result_alloc[loc][role] = 0
for i in range(len(df_result)):
data = df_result.loc[i]
role = data["role"]
location = data["location"]
dict_result_alloc[location][role] += 1
df_result_alloc = pd.DataFrame.from_dict(dict_result_alloc, orient="index")
print("店舗ごとの役職別の割り当て人数")
display(df_result_alloc)
# 各店舗の要求人数情報のラベル ['num_managers' 'num_submanagers']
num_roles_labels = df_req.columns.values[1:num_roles]
dict_result_fill_rate = defaultdict(defaultdict)
# 各店舗ごとに充足率を計算
for l in range(len(df_result_alloc)):
data = df_result_alloc.iloc[l] # 店舗 l の df_result_alloc
loc = data.name # 店舗名
# 各役職ごとに充足率を計算。
for j in range(len(roles) - 1):
# 各店舗・各役職ごとの要求従業員数
num_required = df_req[df_req["location"] == loc][num_roles_labels[j]].item()
# 各店舗・各役職ごとの割り当て従業員数
num_allocated = data[roles[j]].item()
# 充足率を計算。要求従業員数がゼロの場合、N/Aを代入
if num_required > 0:
dict_result_fill_rate[loc][f"{roles[j]} (fill rate)"] = (
num_allocated / num_required
)
else:
dict_result_fill_rate[loc][f"{roles[j]} (fill rate)"] = "N/A"
df_result_fill_rate = pd.DataFrame.from_dict(dict_result_fill_rate, orient="index")
print("店舗ごとの役職別の充足率")
display(df_result_fill_rate)
全店舗において、要求された役職有り従業員が必要人数割り当てされたということが分かります。