0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【OR-Tools実践①】仲が悪いペアも夜勤明けも考慮した「シフト自動作成」(CP-SAT)

0
Last updated at Posted at 2026-06-23

【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 matplotlibVSCodeが選択しているインタープリタに対して実行してください。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)へ続きます。定式化やコードへの質問はコメントでどうぞ。

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?