「組合せ最適化でナーススケジューリング問題を解く」を自分なりに理解してみた

シフトアプリを作ろうとするにあたって
qiita.com
を参考にしてみた。が、pythonの理解に乏しかったので分からなかったところを一つずつメモしていく。

import numpy as np, pandas as pd
from pulp import *
from ortoolpy import addvars, addbinvars

ここで必要なライブラリを入れる。

  • pandas: 変数表を通してモデルを扱うために使用。
  • pulp: 数理最適化モデルを作ることができる。
  • ortoolpy: 変数生成関数(addvars)を使用。
a = pd.read_html('http://qiita.com/shouta-dev/items/1970c2746c3c30f6b39e')[1].T

qiita.com

ここではweb上のページからhtml読み込み、そのうちの表をpandasの形式で変数に代入している。
“[1]”はリストと同じく2番目の表を読み込んでいる。([0]が1番)
“.T”によって転置を行なっている。

実際の運用ではこの部分でExcelやCSVのデータを取り込むことになるだろう。

a,a.columns = a.iloc[1:],a.iloc[0].tolist()
a.必要人数 = a.必要人数.astype(int)
a.iloc[:,2:] = ~a.iloc[:,2:].isnull()
a.insert(0, '曜日', a.index.str[0])
a.reset_index(drop=True, inplace=True)
a = a.iloc[:,list(range(3,a.shape[1]))+[0,1,2]]
a,a.columns = a.iloc[1:],a.iloc[0].tolist()

ここではデータの成形を行なっている。
pandasにおいてcolumnsは列、indexは行を表す。
iloc は 行、列を番号で指定します(先頭が 0)。

df=DataFrame([[1,2,3],
[10,20,30],
[100,200,300],
[1000,2000,3000]],
index=['row_0', 'row_1','row_2','row_3'],
columns=['col_0','col_1','col_2'])
#
# 行番号を指定
#
df.iloc[[2,3]]
#----------------------------
#        col_0  col_1  col_2
# row_2    100    200    300
# row_3   1000   2000   3000
#----------------------------
#
# 列番号を指定
#
df.iloc[:, [1,2]]
#----------------------------
#        col_1  col_2
# row_0      2      3
# row_1     20     30
# row_2    200    300
# row_3   2000   3000
#----------------------------
#
# 行、列番号を指定
#
df.iloc[[2,3], [1,2]]
#----------------------------
#        col_1  col_2
# row_2    200    300
# row_3   2000   3000
#----------------------------
#
# 行、列番号をスライスで指定
#
df.iloc[2:4,1:3]
#----------------------------
#        col_1  col_2
# row_2    200    300
# row_3   2000   3000
#----------------------------
#
# boolean で指定
#
df.iloc[[False,False,True,True],[False,True,True]]
#----------------------------
#        col_1  col_2
# row_2    200    300
# row_3   2000   3000
#----------------------------

今回の場合は一番上の行を除き最後の行までがaに入り

tolistを用いると既存のndarrayをリストへ変換することができる。
aの行要素を指定
f:id:naokichi709:20181121203111p:plain

a.必要人数 = a.必要人数.astype(int)

NumPy配列ndarrayのメソッドastype()でデータ型dtypeを変換(キャスト)することができる。

またpandasでは漢字も使えるみたい。

a.iloc[:,2:] = ~a.iloc[:,2:].isnull()

~(チルダ)はビット反転
isnull()はNaNがあるかどうかをbool値で返す。
NaNであればTrueを返す。それ以外はFalse

これを使って入れる人をTrue、入れない人をFalseに置き換える。

a.insert(0, '曜日', a.index.str[0])

insertは指定した位置に要素を追加(挿入)できる。
第一引数に位置、第二引数に挿入する要素を指定する。先頭(最初)は0。負の値の場合、-1が末尾(最後)の一つ前となる。
a.index.str[0]で1文字目を取り出す。
f:id:naokichi709:20181121213450p:plain

a.reset_index(drop=True, inplace=True)

reset_indexの使い方

df=DataFrame({'X':['A',   'B',   'C',   'D',   'E'],
'Y':['AA',  'BB',  'CC',  'DD',  'EE'],
'Z':['AAA', 'BBB', 'CCC', 'DDD', 'EEE']})
print df
#----------------
#    X   Y    Z
# 0  A  AA  AAA
# 1  B  BB  BBB
# 2  C  CC  CCC
# 3  D  DD  DDD
# 4  E  EE  EEE
#----------------
df.drop([0,2], inplace=True)
print df
#----------------
#    X   Y    Z
# 1  B  BB  BBB
# 3  D  DD  DDD
# 4  E  EE  EEE
#----------------
↑ 削除したらインデックスが歯抜けに!
print df.reset_index(drop=True)
#----------------
#    X   Y    Z
# 0  B  BB  BBB
# 1  D  DD  DDD
# 2  E  EE  EEE
#----------------
↑ reset_index で振り直し
print df.reset_index()
#-----------------------
#    index  X   Y    Z
# 0      1  B  BB  BBB
# 1      3  D  DD  DDD
# 2      4  E  EE  EEE
#-----------------------
↑ 旧インデックス
df.reset_index(drop=True, inplace=True)
print df
#----------------
#    X   Y    Z
# 0  B  BB  BBB
# 1  D  DD  DDD
# 2  E  EE  EEE
#----------------

drop=True を指定しなければ、旧インデックスがデータ列に移動。
inplace=True で、オブジェクトを直接書きかえれる。

a = a.iloc[:,list(range(3,a.shape[1]))+[0,1,2]]

何が起きてるかわかりません。
分かったらまた書きます。

シンプルに0、1、2番目の列を末尾に移動させてるだけですね。。

f:id:naokichi709:20181121221635p:plain

結果から見ると初めの3列が末尾に移動している。

データの整形は完了。

Nコマ,N従業員 = a.shape[0],a.shape[1]-3
L従業員 = list(range(N従業員))
L管理者 = [3,5,9] # 管理者は従業員3, 5, 9
C必要人数差 = 10
C希望不可 = 100
C最低コマ数 = 1
C管理者不足 = 100
C1日2コマ = 10
m = LpProblem() # 数理モデル
V割当 = np.array(addbinvars(Nコマ, N従業員))
a['V必要人数差'] = addvars(Nコマ)
V最低コマ数 = addvars(N従業員)
a['V管理者不足'] = addvars(Nコマ)
V1日2コマ = addvars(N従業員)
m += (C必要人数差 * lpSum(a.V必要人数差)
+ C希望不可 * lpSum(a.apply(lambda r: lpDot(1-r[L従業員],V割当[r.name]), 1))
+ C最低コマ数 * lpSum(V最低コマ数)
+ C管理者不足 * lpSum(a.V管理者不足)
+ C1日2コマ * lpSum(V1日2コマ)) # 目的関数
for _,r in a.iterrows():
m += r.V必要人数差 >=  (lpSum(V割当[r.name]) - r.必要人数)
m += r.V必要人数差 >= -(lpSum(V割当[r.name]) - r.必要人数)
m += lpSum(V割当[r.name,L管理者]) + r.V管理者不足 >= 1
for j,n in enumerate((a.iloc[:,L従業員].sum(0)+1)//2):
m += lpSum(V割当[:,j]) + V最低コマ数[j] >= n # 希望の半分以上
for _,v in a.groupby('曜日'):
for j in range(N従業員):
m += lpSum(V割当[v.index,j]) + V1日2コマ[j] <= 2 # 各曜日で2コマまで
%time m.solve()
R結果 = np.vectorize(value)(V割当).astype(int)
a['結果'] = [''.join(i*j for i,j in zip(r,a.columns)) for r in R結果]
print('目的関数', value(m.objective))
print(a[['曜日','時間帯','結果']])

上の方では定数とか決めてる

Nコマ,N従業員 = a.shape[0],a.shape[1]-3

f:id:naokichi709:20181121230403p:plain

L従業員 = list(range(N従業員))

f:id:naokichi709:20181121232117p:plain

m = LpProblem()

数理モデルを作成しています。
最小化問題のとき: m = LpPrblem()
最大化問題のとき: m = LpProblem(sense=LpMaximize)

V割当 = np.array(addbinvars(Nコマ, N従業員))

この場合はNコマ×N従業員(21×10)の0−1の変数を用意している。
漢字使えるのは驚き

a['V必要人数差'] = addvars(Nコマ)

ここでaにNコマ分のindex(列)に「V必要人数差」という要素を追加する。
同じく0-1の変数を作成

f:id:naokichi709:20181122105509p:plainf:id:naokichi709:20181122105513p:plainf:id:naokichi709:20181122105515p:plain
以下v210まで

m += (C必要人数差 * lpSum(a.V必要人数差)
+ C希望不可 * lpSum(a.apply(lambda r: lpDot(1-r[L従業員],V割当[r.name]), 1))
+ C最低コマ数 * lpSum(V最低コマ数)
+ C管理者不足 * lpSum(a.V管理者不足)
+ C1日2コマ * lpSum(V1日2コマ)) # 目的関数

m += 式  の形で式の部分を最小化することが今回の数理モデル

C必要人数差は定数でありa. V必要人数差は変数(0-1)。lpSumはその列の合計。つまり今回ではV必要人数差21個分の合計。

  + C希望不可 * lpSum(a.apply(lambda r: lpDot(1-r[L従業員],V割当[r.name]), 1))

この行はC希望不可という定数とlpSumの合計をかけたもの
LpSumの中身は少し複雑
中身を理解するためにa.apply(lambda r: r,1)を実行してみる。
f:id:naokichi709:20181124192402p:plain
applyの第二引数の1はaxis=1と同義
無名関数lambda rにはaが入る
a.apply(lambda r: 1-r[L従業員],1)を実行してみると
f:id:naokichi709:20181124144434p:plain
sinhrks.hatenablog.com
のDataFrame参照
つまり各行に関してaを参照して、r[L従業員](L従業員は[0,1,2,3,4,5,6,7,8,9]という配列)で0~9 のTrueかFalseを取り出し、1-True = 0 もしくは1-False = 1という形で出力。

a.apply(lambda r: V割当[r.name],1)

f:id:naokichi709:20181124193415p:plain

V割当[r.name]がなぜこんな結果になるのかは分からないがV割当が10×21のV割当を作成
これらをlpDotで内積をとる。
つまりV割当が1を取ったところとシフトの希望不可を取ったところが1を取りその合計とC希望不可をかける。

for _,r in a.iterrows():
m += r.V必要人数差 >=  (lpSum(V割当[r.name]) - r.必要人数)
m += r.V必要人数差 >= -(lpSum(V割当[r.name]) - r.必要人数)
m += lpSum(V割当[r.name,L管理者]) + r.V管理者不足 >= 1
for _,r in a.iterrows():

iterrows()メソッドを使うと、1行ずつ、インデックス名(行名)とその行のデータ(pandas.Series型)のタプル(index, Series)を取得できる。
Python で使わないタプルの値は “_” (アンダーバー) に代入するという慣例みたいなものがあるのでこの場合ではindex名は使わないのでアンダーバーを使っていると思われる。

 m += r.V必要人数差 >=  (lpSum(V割当[r.name]) - r.必要人数)
m += r.V必要人数差 >= -(lpSum(V割当[r.name]) - r.必要人数)

ここではアサイン数と必要人数が一致するようにしていると思われる。
r.V必要人数差 は0か1なのでlpSum(V割当[r.name]) – r.必要人数が2以上にならないように制約
また(lpSum(V割当[r.name]) – r.必要人数)がマイナスの値になった時も制約が必要になるので
そのための2行目
(目的関数でC必要人数差 * lpSum(a.V必要人数差)があるのでr.V必要人数差 は0が最適になる)

lpSum(V割当[r.name,L管理者]) + r.V管理者不足 >= 1

ここでは管理者が一人もアサインしていなければr.V管理者不足が1となり目的関数がプラスになる仕組み(目的関数は0が一番良くてプラスになる程だめ)

for j,n in enumerate((a.iloc[:,L従業員].sum(0)+1)//2):
   m += lpSum(V割当[:,j]) + V最低コマ数[j] >= n # 希望の半分以上
for _,v in a.groupby('曜日'):
   for j in range(N従業員):
      m += lpSum(V割当[v.index,j]) - V1日2コマ[j] <= 2 # 各曜日で2コマまで

同じように制約を加えていきこれで定式化の完了です。

%time m.solve()

これで最適なV割当を探索します。%timeで計算時間も測れます。

R結果 = np.vectorize(value)(V割当).astype(int)
a['結果'] = [''.join(i*j for i,j in zip(r,a.columns)) for r in R結果]
print('目的関数', value(m.objective))
print(a[['曜日','時間帯','結果']])

この部分ではどういう割り当てになったのかを可視化するための出力をしています。

これで一通りのコードを見て回りました。他の人のコードを読むのは自分の知らないことをたくさん学ぶ良い機会になると実感しました。

何か間違っているところ、わからないところがありましたらコメントで教えていただけると幸いです。

コメント

タイトルとURLをコピーしました