組合せ最適化でナーススケジューリング問題を解く

# これなに
「[遺伝的アルゴリズムでナーススケジューリング問題（シフト最適化）を解く](http://qiita.com/shouta-dev/items/1970c2746c3c30f6b39e)」を[組合せ最適化](http://qiita.com/SaitoTsutomu/items/bfbf4c185ed7004b5721)で解いてみました。

# 表(シフト希望等)を読む

```py3:python
import numpy as np, pandas as pd
from pulp import *
from ortoolpy import addvars, addbinvars
from io import StringIO

a = pd.read_table(StringIO("""\
曜日\t月\t月\t月\t火\t火\t火\t水\t水\t水\t木\t木\t木\t金\t金\t金\t土\t土\t土\t日\t日\t日
時間帯\t朝\t昼\t夜\t朝\t昼\t夜\t朝\t昼\t夜\t朝\t昼\t夜\t朝\t昼\t夜\t朝\t昼\t夜\t朝\t昼\t夜
必要人数\t2\t3\t3\t2\t3\t3\t2\t3\t3\t1\t2\t2\t2\t3\t3\t2\t4\t4\t2\t4\t4
従業員0\t○\t\t\t○\t\t\t○\t\t\t○\t\t\t○\t\t\t○\t\t\t○\t\t
従業員1\t○\t○\t○\t\t\t\t○\t○\t○\t\t\t\t○\t○\t○\t\t\t\t\t\t
従業員2\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t○\t○\t○\t○\t○\t○
従業員3\t○\t○\t○\t○\t○\t○\t○\t○\t○\t○\t○\t○\t○\t○\t○\t○\t○\t○\t○\t○\t○
従業員4\t\t\t○\t\t\t○\t\t\t○\t\t\t○\t\t\t○\t\t\t○\t\t\t○
従業員5\t○\t○\t○\t○\t○\t○\t○\t○\t○\t○\t○\t○\t○\t○\t○\t\t\t\t\t\t
従業員6\t\t\t\t\t\t\t\t\t\t\t\t\t○\t○\t○\t○\t○\t○\t○\t○\t○
従業員7\t\t○\t\t\t○\t\t\t○\t\t\t○\t\t\t○\t\t\t○\t\t\t○\t
従業員8\t\t\t○\t\t\t○\t\t\t○\t\t\t○\t\t\t○\t\t\t○\t\t\t○
従業員9\t\t\t\t\t\t\t\t\t\t○\t○\t○\t○\t○\t○\t○\t○\t○\t○\t○\t○""")).T
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]]
print(a[:3]) # 最初の3行表示
```

<table>
  <thead>
      <th></th>
      <th>従業員0</th>
      <th>従業員1</th>
      <th>従業員2</th>
      <th>従業員3</th>
      <th>従業員4</th>
      <th>従業員5</th>
      <th>従業員6</th>
      <th>従業員7</th>
      <th>従業員8</th>
      <th>従業員9</th>
      <th>曜日</th>
      <th>時間帯</th>
      <th>必要人数</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th>0</th>
      <td>True</td>
      <td>True</td>
      <td>False</td>
      <td>True</td>
      <td>False</td>
      <td>True</td>
      <td>False</td>
      <td>False</td>
      <td>False</td>
      <td>False</td>
      <td>月</td>
      <td>朝</td>
      <td>2</td>
    </tr>
    <tr>
      <th>1</th>
      <td>False</td>
      <td>True</td>
      <td>False</td>
      <td>True</td>
      <td>False</td>
      <td>True</td>
      <td>False</td>
      <td>True</td>
      <td>False</td>
      <td>False</td>
      <td>月</td>
      <td>昼</td>
      <td>3</td>
    </tr>
    <tr>
      <th>2</th>
      <td>False</td>
      <td>True</td>
      <td>False</td>
      <td>True</td>
      <td>True</td>
      <td>True</td>
      <td>False</td>
      <td>False</td>
      <td>True</td>
      <td>False</td>
      <td>月</td>
      <td>夜</td>
      <td>3</td>
    </tr>  </tbody>
</table>

# Pythonで解く
V割当等の 「V…」は変数です。

```py3:python
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[['曜日','時間帯','結果']])
```

```text:出力
CPU times: user 7.45 ms, sys: 4.23 ms, total: 11.7 ms
Wall time: 22.8 ms
目的関数 0.0
   曜日 時間帯                結果
0   月   朝          従業員1従業員5
1   月   昼      従業員3従業員5従業員7
2   月   夜      従業員1従業員3従業員4
3   火   朝          従業員0従業員3
4   火   昼      従業員3従業員5従業員7
5   火   夜      従業員4従業員5従業員8
6   水   朝          従業員0従業員5
7   水   昼      従業員1従業員3従業員5
8   水   夜      従業員3従業員4従業員8
9   木   朝              従業員3
10  木   昼          従業員5従業員7
11  木   夜          従業員8従業員9
12  金   朝          従業員1従業員5
13  金   昼      従業員1従業員7従業員9
14  金   夜      従業員5従業員6従業員8
15  土   朝          従業員0従業員3
16  土   昼  従業員2従業員6従業員7従業員9
17  土   夜  従業員3従業員4従業員6従業員9
18  日   朝          従業員0従業員9
19  日   昼  従業員2従業員3従業員6従業員9
20  日   夜  従業員2従業員3従業員4従業員6
```

- 計算時間は23ミリ秒で、厳密に最適な解が得られました。
- 目的関数(ペナルティ総和)は $0$ なので、すべての条件を満たしています。

以上

----

参考

- [組合せ最適化 - 典型問題 - 勤務スケジューリング問題](http://qiita.com/SaitoTsutomu/items/e79ad9ca61a82d5482fa)