【OR-Tools実践①】仲が悪いペアも夜勤明けも考慮した「シフト自動作成」(CP-SAT)
はじめに:なぜ「予測」より「最適化」なのか
機械学習による需要予測は華やかですが、現場では「で、結局この人数をどう配置するの?」という問いが残ります。一方、数理最適化(OR)は出力がそのまま”行動計画”になるため、効果が数字で見えやすい領域です。
そして、その「行動計画づくり」の多くはいまだに手作業です。あるシフト管理サービスが2025年に全国1,242社へ行った調査では、全体の約72.5%が紙やExcelでシフトを運用しており、介護・福祉業界では約82.6%にのぼると報告されています。ベテラン担当者が毎月、頭の中の暗黙ルールをパズルのように埋めているわけです。
本記事では、オープンソースの OR-Tools(Google製)だけで、この「シフト作成」を自動化します。ポイントは、教科書の綺麗な問題ではなく、**「AさんとBさんは仲が悪い」「夜勤明けは必ず休み」**といった現場の制約をあえて盛り込むことです。
配送ルート編は別記事【OR-Tools実践②】荷下ろし時間と時間枠を考慮した「配送ルート最適化」(VRPTW)にまとめています。
実行環境
ローカルでの環境構築(インタープリタやパッケージの取り違え)でつまずきやすいので、Google Colab で動かすのがおすすめです。ブラウザだけで完結します。Colab の標準ランタイム(2026年6月時点で Python 3.12 / Ubuntu 22.04)で動作確認しています。
ノートブックの最初のセルで OR-Tools を入れます(pandas・matplotlib は Colab に最初から入っています)。
!pip install ortools
図(シフト表・ルート図)で日本語を表示する場合は、日本語フォントも入れておきます(入れないと文字が □ に化けます)。
!apt-get -y install fonts-noto-cjk > /dev/null
| ソフトウェア | バージョン |
|---|---|
| 実行環境 | Google Colab(Python 3.12 / Ubuntu 22.04、2026年6月時点) |
| OR-Tools | 9.15.6755(pip install で導入) |
| pandas / matplotlib | Colab 同梱版 |
ローカル(VSCode等)で動かす場合は、
pip install ortools pandas matplotlibをVSCodeが選択しているインタープリタに対して実行してください。Colabで動いてローカルで動かない原因は、ほぼ「pipした環境と実行している環境の取り違え」です。
図を生成したあと Colab 上で表示したいときは
from IPython.display import Image; Image("ファイル名.png")を実行します。表(DataFrame)は、セルの最後にdfと書くだけで自動で整形された表として表示されます。
1. 問題設定
8人の従業員(正社員5・パート3)で、2週間(14日)×3シフト(早番・遅番・夜勤)を回します。ここに現場の事情を制約として積み上げます。
| # | 制約 | 種類 | 意味 |
|---|---|---|---|
| A | 1人1日に入るシフトは最大1つ | ハード | 物理的に当然 |
| B | 各シフトの必要人数を満たす | ソフト(高) | 不足は最後の手段として許容し「解なしで終わらない」設計に |
| C | 各シフトにリーダー資格者を1人 | ソフト(高) | リーダーは人数が限られるため、不在は高ペナルティで許容 |
| D | 夜勤明けは必ず休み | ハード | 健康・労務への配慮 |
| E | パートは夜勤に入れない | ハード | 雇用契約上の事情 |
| F | 仲が悪いペアは同居させない | ハード | 現場の人間関係 |
| G | 連続勤務は最大4日 | ハード | 疲労管理 |
| H | パートは2週間で最大8勤務 | ハード | 労働時間の上限 |
| I | 希望休をできるだけ叶える | ソフト | 従業員満足度(後述) |
| J | 夜勤回数・総勤務数を公平に | ソフト | 不公平は離職に直結する |
2. 設計のキモ:ハード制約とソフト制約を分ける
最初、「リーダーは全シフトに必須」をハード制約にしたところ、いきなり INFEASIBLE(解なし)になりました。リーダー資格者が3人だと、全シフトに毎日立たせるのが「夜勤明け休み」「連勤上限」と両立できないためです。
現場でも「リーダー不在は極力避けるが、どうしても無理なら許す」のが自然です。そこでスラック変数+ペナルティでソフト化し、「解は必ず出る。ただし望ましくない事象には目的関数で値段をつける」という設計にします。これは実務で最も重要なテクニックです。
# リーダーが1人もいなければ no_leader=1 を許し、目的関数で高ペナルティを課す
nl = model.NewBoolVar(f"noleader_{d}_{s}")
model.Add(sum(work[e, d, s] for e in leaders) + nl >= 1)
# ...
penalties += [500 * no_leader[d, s] for (d, s) in no_leader]
逆に「仲が悪いペア」はハード制約として、同じ日・同じシフトに2人が立つことを禁止します。
for a, b in INCOMPATIBLE:
ea, eb = name2idx[a], name2idx[b]
for d in range(DAYS):
for s in range(S):
model.Add(work[ea, d, s] + work[eb, d, s] <= 1)
3. 「希望休」はどう実装されているか
ご質問の多い「希望休」を詳しく説明します。希望休はソフト制約として扱います。「絶対に叶える」のではなく「できるだけ叶える」という性質だからです。
まず、従業員ごとに「休みたい日」を登録します。
DAYOFF_REQUESTS = { # 日付は表示と揃えて 1 始まり(D1〜D14)
"Aさん": [4, 5],
"Fさん": [1, 2, 8, 9],
"Gさん": [3, 6, 10, 13],
"Eさん": [10],
}
実装はシンプルで、**「希望日に出勤させてしまったらペナルティ80を課す」**だけです。worked[e, d] はその日に何かしら働けば1になる変数なので、これに重みを掛けて目的関数へ足します。
for name, days in DAYOFF_REQUESTS.items():
e = name2idx[name]
for d in days: # d は 1 始まりなので d-1 で参照
penalties.append(80 * worked[e, d - 1]) # 希望日に働いたら +80
ソルバーは全ペナルティの総和を最小化します。つまり「希望日に出勤させる」コストより安く済む解があるなら、自動的に休ませてくれます。重要なのは重みの大小が優先順位そのものになる点です。本記事では次の階層にしています。
| 事象 | ペナルティ | 意味 |
|---|---|---|
| 人員不足 | 1000 | 絶対に避けたい |
| リーダー不在 | 500 | できるだけ避けたい |
| 希望休が叶わない | 80 | なるべく叶える |
| 夜勤回数の不公平 | 20(最大-最小) | 均したい |
| 総勤務数の不公平 | 10(最大-最小) | 均したい |
この階層が「店が回らなくなるくらいなら、希望休は我慢してもらう」という現場の常識を、そのまま数値で表現しています。希望休の重みを 80 → 300 のように上げれば「多少人手が苦しくても希望休を優先」へ、下げれば逆へと、現場ごとの価値観に合わせて調整できます。
4. 全コード
# -*- coding: utf-8 -*-
"""
現場の制約を盛り込んだ「シフト自動作成」
==================================================
OR-Tools CP-SAT を使い、教科書的な綺麗な問題ではなく、
現実の泥臭い制約(仲が悪いペア・夜勤明けは休み・希望休・スキル要件…)を
盛り込んだナーススケジューリング型の問題を解く。
required: ortools
"""
from ortools.sat.python import cp_model
import pandas as pd
# 日本語(全角)を含む表を等幅で正しく揃えて表示するための設定。
# これが無いと pandas が全角を半角幅で数え、列がズレて表が崩れて見える。
pd.set_option("display.unicode.east_asian_width", True)
# ----------------------------------------------------------------------
# 1. 問題データ(現場設定)
# ----------------------------------------------------------------------
# 従業員: 名前 / スキル(リーダー資格) / 雇用形態
EMPLOYEES = [
{"name": "Aさん", "leader": True, "type": "正社員"},
{"name": "Bさん", "leader": True, "type": "正社員"},
{"name": "Cさん", "leader": True, "type": "正社員"},
{"name": "Dさん", "leader": False, "type": "正社員"},
{"name": "Eさん", "leader": True, "type": "正社員"},
{"name": "Fさん", "leader": False, "type": "パート"},
{"name": "Gさん", "leader": False, "type": "パート"},
{"name": "Hさん", "leader": False, "type": "パート"},
]
N = len(EMPLOYEES)
DAYS = 14 # 2週間
SHIFTS = ["早番", "遅番", "夜勤"]
S = len(SHIFTS)
NIGHT = SHIFTS.index("夜勤")
# 各シフトに必要な人数(曜日で需要を変える: 週末は少し増やす)
def required_staff(day, shift):
weekend = (day % 7) in (5, 6) # 土日
base = {"早番": 2, "遅番": 2, "夜勤": 1}[SHIFTS[shift]]
return base + (1 if (weekend and SHIFTS[shift] == "早番") else 0)
# 仲が悪いペア: 同じ日・同じシフトに入れない
INCOMPATIBLE = [("Cさん", "Dさん"), ("Aさん", "Hさん")]
# 希望休(ソフト制約: できるだけ叶える) name -> [日付, ...]
# 日付は表示と揃えて 1 始まり(D1〜D14)。内部の配列インデックスは d-1 で参照する。
DAYOFF_REQUESTS = {
"Aさん": [4, 5],
"Fさん": [1, 2, 8, 9], # パート: 平日のみなど事情あり
"Gさん": [3, 6, 10, 13],
"Eさん": [10],
}
MAX_CONSECUTIVE_WORK = 4 # 連続勤務は最大4日
name2idx = {e["name"]: i for i, e in enumerate(EMPLOYEES)}
# ----------------------------------------------------------------------
# 2. モデル構築
# ----------------------------------------------------------------------
model = cp_model.CpModel()
# 決定変数 work[e,d,s] = 1 なら 従業員e が 日d に シフトs に入る
work = {}
for e in range(N):
for d in range(DAYS):
for s in range(S):
work[e, d, s] = model.NewBoolVar(f"w_{e}_{d}_{s}")
# --- 制約A: 1人1日に入れるシフトは最大1つ ---
for e in range(N):
for d in range(DAYS):
model.Add(sum(work[e, d, s] for s in range(S)) <= 1)
# --- 制約B: 必要人数を満たす(不足はペナルティ付きで許容=必ず実行可能にする)---
shortage = {}
for d in range(DAYS):
for s in range(S):
need = required_staff(d, s)
assigned = sum(work[e, d, s] for e in range(N))
short = model.NewIntVar(0, need, f"short_{d}_{s}")
model.Add(assigned + short >= need)
shortage[d, s] = short
# --- 制約C: 各シフトにリーダー資格者を最低1人(ソフト: 不在は高ペナルティで許容)---
# リーダーは3人しかいないため「全シフト毎日必須」をハードにすると実行不能になる。
# 現実でも「不在は極力避けるが最後の手段として許す」のが自然なのでソフト化する。
leaders = [e for e in range(N) if EMPLOYEES[e]["leader"]]
no_leader = {}
for d in range(DAYS):
for s in range(S):
if required_staff(d, s) >= 1:
nl = model.NewBoolVar(f"noleader_{d}_{s}")
# リーダーが1人もいなければ nl=1 を許す
model.Add(sum(work[e, d, s] for e in leaders) + nl >= 1)
no_leader[d, s] = nl
# --- 制約D: 夜勤明けは必ず休み(翌日はどのシフトにも入らない)---
for e in range(N):
for d in range(DAYS - 1):
for s in range(S):
model.Add(work[e, d + 1, s] == 0).OnlyEnforceIf(work[e, d, NIGHT])
# --- 制約E: パートは夜勤に入らない ---
for e in range(N):
if EMPLOYEES[e]["type"] == "パート":
for d in range(DAYS):
model.Add(work[e, d, NIGHT] == 0)
# --- 制約F: 仲が悪いペアは同じ日・同じシフトに同居させない ---
for a, b in INCOMPATIBLE:
ea, eb = name2idx[a], name2idx[b]
for d in range(DAYS):
for s in range(S):
model.Add(work[ea, d, s] + work[eb, d, s] <= 1)
# --- 制約G: 連続勤務は最大 MAX_CONSECUTIVE_WORK 日 ---
worked = {} # その日 何かしら働いたか
for e in range(N):
for d in range(DAYS):
w = model.NewBoolVar(f"worked_{e}_{d}")
model.Add(w == sum(work[e, d, s] for s in range(S)))
worked[e, d] = w
for e in range(N):
for d in range(DAYS - MAX_CONSECUTIVE_WORK):
model.Add(
sum(worked[e, d + k] for k in range(MAX_CONSECUTIVE_WORK + 1))
<= MAX_CONSECUTIVE_WORK
)
# --- 制約H: パートは2週間で最大8勤務(労働時間上限の簡易表現)---
for e in range(N):
if EMPLOYEES[e]["type"] == "パート":
model.Add(sum(worked[e, d] for d in range(DAYS)) <= 8)
# ----------------------------------------------------------------------
# 3. 目的関数(ソフト制約の重み付き和を最小化)
# ----------------------------------------------------------------------
penalties = []
# (1) 人員不足: 絶対に避けたいので最大の重み
penalties += [1000 * shortage[d, s] for d in range(DAYS) for s in range(S)]
# (1b) リーダー不在: 次に避けたい
penalties += [500 * no_leader[d, s] for (d, s) in no_leader]
# (2) 希望休が叶わなかった回数
for name, days in DAYOFF_REQUESTS.items():
e = name2idx[name]
for d in days:
penalties.append(80 * worked[e, d - 1]) # 希望日(1始まり)に働いたらペナルティ
# (3) 夜勤回数の公平性: 最大-最小を縮める
night_counts = []
for e in range(N):
if EMPLOYEES[e]["type"] != "パート":
nc = model.NewIntVar(0, DAYS, f"night_{e}")
model.Add(nc == sum(work[e, d, NIGHT] for d in range(DAYS)))
night_counts.append(nc)
max_night = model.NewIntVar(0, DAYS, "max_night")
min_night = model.NewIntVar(0, DAYS, "min_night")
model.AddMaxEquality(max_night, night_counts)
model.AddMinEquality(min_night, night_counts)
penalties.append(20 * (max_night - min_night))
# (4) 総勤務数の公平性(正社員間)
total_counts = []
for e in range(N):
if EMPLOYEES[e]["type"] != "パート":
tc = model.NewIntVar(0, DAYS, f"total_{e}")
model.Add(tc == sum(worked[e, d] for d in range(DAYS)))
total_counts.append(tc)
max_tot = model.NewIntVar(0, DAYS, "max_tot")
min_tot = model.NewIntVar(0, DAYS, "min_tot")
model.AddMaxEquality(max_tot, total_counts)
model.AddMinEquality(min_tot, total_counts)
penalties.append(10 * (max_tot - min_tot))
model.Minimize(sum(penalties))
# ----------------------------------------------------------------------
# 4. 求解
# ----------------------------------------------------------------------
solver = cp_model.CpSolver()
solver.parameters.max_time_in_seconds = 30.0
# 再現性のため決定論的に解く(並列ワーカーを1・乱数シードを固定)。
# 並列(num_search_workers>1)だと目的関数値が同じでも具体的な解が毎回変わりうる。
solver.parameters.num_search_workers = 1
solver.parameters.random_seed = 42
status = solver.Solve(model)
print("=" * 60)
print(f"探索ステータス : {solver.StatusName(status)}")
print(f"目的関数値 : {solver.ObjectiveValue():.0f}")
print(f"求解時間 : {solver.WallTime():.2f} 秒")
print("=" * 60)
# ----------------------------------------------------------------------
# 5. 結果の表示
# ----------------------------------------------------------------------
if status in (cp_model.OPTIMAL, cp_model.FEASIBLE):
# シフト表(行=従業員, 列=日)
grid = []
for e in range(N):
row = []
for d in range(DAYS):
cell = "・"
for s in range(S):
if solver.Value(work[e, d, s]):
cell = SHIFTS[s][0] + SHIFTS[s][1] # 早番→早番
row.append(cell)
grid.append(row)
cols = [f"D{d+1}{'(土)' if d%7==5 else '(日)' if d%7==6 else ''}" for d in range(DAYS)]
# 行ラベルに ★(リーダー資格) と (P)(パート) を付け、誰が誰か一目で分かるようにする
def row_label(e):
return (EMPLOYEES[e]["name"]
+ ("★" if EMPLOYEES[e]["leader"] else "")
+ ("(P)" if EMPLOYEES[e]["type"] == "パート" else ""))
df = pd.DataFrame(grid, index=[row_label(e) for e in range(N)], columns=cols)
print("\n■ 完成したシフト表(★=リーダー資格 / (P)=パート)")
print(df.to_string())
# 検証サマリ
print("\n■ 制約の充足チェック")
total_short = sum(solver.Value(shortage[d, s]) for d in range(DAYS) for s in range(S))
print(f" 人員不足の合計 : {total_short} 人日")
print(f"\n 夜勤回数(正社員):")
for e in range(N):
if EMPLOYEES[e]["type"] != "パート":
nc = sum(solver.Value(work[e, d, NIGHT]) for d in range(DAYS))
print(f" {EMPLOYEES[e]['name']}: {nc} 回")
print(f"\n 希望休の充足:")
granted = total = 0
for name, days in DAYOFF_REQUESTS.items():
e = name2idx[name]
for d in days:
total += 1
if not solver.Value(worked[e, d - 1]):
granted += 1
print(f" {granted}/{total} 件 叶えられました")
# 仲が悪いペアの検証
print(f"\n 仲が悪いペアの同居違反:")
for a, b in INCOMPATIBLE:
ea, eb = name2idx[a], name2idx[b]
viol = 0
for d in range(DAYS):
for s in range(S):
if solver.Value(work[ea, d, s]) and solver.Value(work[eb, d, s]):
viol += 1
print(f" {a}×{b}: {viol} 件(0なら成功)")
else:
print("実行可能な解が見つかりませんでした")
5. 実行結果
探索ステータス : OPTIMAL
目的関数値 : 110
求解時間 : 約13秒
人員不足の合計 : 0 人日
夜勤回数(正社員) : Aさん4 / Bさん3 / Cさん3 / Dさん3 / Eさん4 回
希望休の充足 : 10/11 件 叶えられました
仲が悪いペアの同居違反 : Cさん×Dさん 0件 / Aさん×Hさん 0件
OPTIMAL は「これ以上良い解は存在しないと証明できた」状態です。人員不足ゼロ、夜勤回数はほぼ均等(3〜4回)、仲が悪いペアの同居は完全にゼロ。希望休は11件中10件を充足し、唯一叶わなかったのはAさんのD5の1件だけでした。
この1件が叶わない理由も明快です。Aさんを含むリーダー資格者は数が少なく、D5周辺で休ませると人員・リーダー要件(ペナルティ1000・500)の方が希望休(80)より高くつくため、ソルバーは「ここは出勤」と判断しています。目的関数値110の内訳は 叶わなかった希望休1件(80) + 公平性の微差(20+10) だけ。つまり「なぜこの解なのか」を現場に説明できる状態になっています。
完成したシフト表は次の通りです(★=リーダー資格 / (P)=パート / 休=休み)。
1週目(D1〜D7)
| 担当 | D1 月 | D2 火 | D3 水 | D4 木 | D5 金 | D6 土 | D7 日 |
|---|---|---|---|---|---|---|---|
| Aさん★ | 早番 | 早番 | 夜勤 | 休 | 早番 | 夜勤 | 休 |
| Bさん★ | 遅番 | 夜勤 | 休 | 早番 | 遅番 | 早番 | 夜勤 |
| Cさん★ | 休 | 遅番 | 遅番 | 遅番 | 夜勤 | 休 | 遅番 |
| Dさん | 早番 | 早番 | 早番 | 夜勤 | 休 | 遅番 | 夜勤 |
| Eさん★ | 夜勤 | 休 | 早番 | 夜勤 | 休 | 遅番 | 早番 |
| Fさん(P) | 休 | 休 | 遅番 | 休 | 遅番 | 早番 | 早番 |
| Gさん(P) | 遅番 | 休 | 休 | 遅番 | 早番 | 休 | 早番 |
| Hさん(P) | 休 | 遅番 | 休 | 早番 | 休 | 早番 | 遅番 |
2週目(D8〜D14)
| 担当 | D8 月 | D9 火 | D10 水 | D11 木 | D12 金 | D13 土 | D14 日 |
|---|---|---|---|---|---|---|---|
| Aさん★ | 夜勤 | 休 | 遅番 | 遅番 | 遅番 | 夜勤 | 休 |
| Bさん★ | 休 | 早番 | 早番 | 夜勤 | 休 | 遅番 | 遅番 |
| Cさん★ | 早番 | 遅番 | 夜勤 | 休 | 早番 | 早番 | 夜勤 |
| Dさん | 休 | 早番 | 早番 | 夜勤 | 休 | 遅番 | 早番 |
| Eさん★ | 遅番 | 夜勤 | 休 | 早番 | 夜勤 | 休 | 早番 |
| Fさん(P) | 休 | 休 | 遅番 | 早番 | 早番 | 早番 | 休 |
| Gさん(P) | 早番 | 休 | 休 | 遅番 | 遅番 | 休 | 早番 |
| Hさん(P) | 遅番 | 遅番 | 休 | 休 | 休 | 早番 | 遅番 |
夜勤の翌日が必ず「休」になっている点、仲が悪いCさん★×Dさんが同じ日の同じシフトに並ばない点に注目してください。
再現性についての注記:上記は
num_search_workers=1(並列なし)+random_seed=42で毎回まったく同じ解が出る設定です。num_search_workers=8にすると約1秒で解けますが、最適値110は同じでも具体的な配置は実行ごとに変わり得ます(最適解が複数存在するため)。記事や納品物として「同じ表」を再現したい場合は並列を1にしてください。
まとめ
- 現場の「見え辛い制約」は、ハード(絶対)とソフト(ペナルティ)に仕分けるのが設計の核心。
- 希望休のような「できるだけ叶えたい」要望は、重み付きペナルティの大小=優先順位として表現できる。
- 商用ソルバーなしでも、この規模なら最適性を証明したうえで現実的な時間で解ける。
配送ルート編は【OR-Tools実践②】荷下ろし時間と時間枠を考慮した「配送ルート最適化」(VRPTW)へ続きます。定式化やコードへの質問はコメントでどうぞ。