3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

「Julia×JuMPで病棟シフト最適化③:Excelから希望休を読込&最適化」

3
Last updated at Posted at 2025-08-24

1. はじめに

看護師シフトづくりでは、希望休や要員数、禁止パターンが複雑に絡み合いがちです。 この記事では、Excelにまとめたスタッフの希望データを読み込んで、 最適化するワークフローを解説します。
Excelは下記画像のデータを使用することとします。

20250821.png

前回は、夜勤・日勤のバランスを目的関数に反映し、6日連続勤務禁止などの動的制約を構築しました。

ここでは、 Excelに入力した「希望休」「目標休日数」「目標夜勤日数」「日勤者数」「早番数」

を読み込み、最適化モデルに反映する方法を紹介します。

2.コード全体の解説

以下では、実装した最適化コードを大きく6つのパートに分けて、それぞれの役割やポイントを説明します。

2.1 ライブラリ読み込み

using JuMP
using HiGHS
using DataFrames
using IterTools
using PrettyTables
using XLSX
using Dates

2.2 定数・セル範囲の定義

const SHIFT_TYPE = Symbol.(['休', '日', '夜', '明', '早'])

const KINSHI_SHIFT = Symbol.(["夜夜", "休明", "明明", "明夜", 
                        "夜休", "夜日", "日明", "明日",
                        "夜早","早明","明早"
                    ])

const DATES_CELL           = "F2:AJ2"
const HEADERS_CELL         = "B2:AM2"
const DATA_CELL            = "B3:AM27"
const NIKKIN_TARGET_CELL   = "F28:AJ28"
const NIKKIN_KYOYOCHI_CELL = "F29:AJ29"
const HAYABAN_TARGET_CELL  = "F30:AJ30"
  • SHIFT_TYPE:扱う勤務区分をSymbol配列で定義
  • KINSHI_SHIFT:禁止連続パターン(文字列)をSymbol配列に変換
  • DATES_CELL~HAYABAN_TARGET_CELL:Excelシート上のセル範囲を定数化

2.3 Excel読み込みと前処理

ws         = XLSX.readxlsx("shift_kibou.xlsx")["メインシート"]
days_list  = vec(ws[DATES_CELL])
headers    = DataFrame(ws[HEADERS_CELL], :auto)[1, :]
staff_data = coalesce.(DataFrame(ws[DATA_CELL], Symbol.(headers)), "")
  • ws:対象シートのデータを取得
  • days_list:日付セルから配列抽出
  • headers:列名(id, 日付、目標休日日数など)取得
  • staff_data:希望休/目標値をDataFrameにし、空欄は""に置換

2.4 モデルと変数定義

model = Model(HiGHS.Optimizer)

@variable(model, x[staff_data.id, days_list, k in SHIFT_TYPE], Bin)
  • model:HiGHSを使った最適化モデルを生成
  • x[s, d, k]:スタッフ s、日付 d、勤務区分 k の割り当てを示すバイナリ変数

2.5 基本制約

for s in staff_data.id, d in days_list
    @constraint(model, sum(x[s, d, k] for k in SHIFT_TYPE) == 1)
end
  • 各スタッフ・各日に必ず1つのシフト区分を割り当て
for (s, d) in product(staff_data.id, days_list)
    request = staff_data[staff_data.id .== s, string(d)][1]
    if request !== ""
        @constraint(model, x[s, d, Symbol(request)] == 1)
    end
end
  • Excel上で指定がある場合、そのシフト区分を強制的に設定

2.6 目標休日日数の確保


for staff in eachrow(staff_data)
    s = staff.id
    day_shift_count = @expression(model, sum(x[s, d, SHIFT_TYPE[1]] for d in days_list))

    target_num = Int64(staff.目標休み日数)
    if occursin("+", staff.目標休み許容値)
        @constraint(model, day_shift_count - target_num <= parse(Int64, replace(staff.目標休み許容値, "+" => "")))
        @constraint(model, day_shift_count - target_num >= 0)
    else
        @constraint(model, day_shift_count == target_num)
    end
end
  • 許容値付き(+n)なら範囲制約を設定
  • 固定値のみなら等式制約を設定

2.7 夜勤・明け・早番要員数の確保

for d in days_list
    @constraint(model, sum(x[s, d, SHIFT_TYPE[3]] for s in staff_data.id) == 2)
end

for d in days_list
    @constraint(model, sum(x[s, d, SHIFT_TYPE[4]] for s in staff_data.id) == 2)
end
  • 夜勤要員:日ごとに2名必要
  • 明け要員:日ごとに2名必要
for d in days_list
    num = 1
    if ws[HAYABAN_TARGET_CELL][d] isa Number
        num = ws[HAYABAN_TARGET_CELL][d]
    end
    @constraint(model, sum(x[s, d, SHIFT_TYPE[5]] for s in staff_data.id) == num)
end
  • 早番要員:指定があればその数、なければ1名

2.8 禁止パターンの適用

for (k, s, d) in product(KINSHI_SHIFT, staff_data.id, days_list)
    k = String(k)
    t = length(k) - 1
    if d > t
        @constraint(model, sum(x[s, d - t + h , Symbol(k[nextind(k, h)])] for h in 0:t) <= 1)
    end
end
  • “夜夜” や “休明” のような連続禁止パターンを設定
    (病院のルールに基づいた禁止パターンをKINSHI_SHIFTに設定しておきます)

2.9 スタッフ毎の夜勤回数の上限調整

スタッフごとに夜勤回数の上限を設定し、過度な夜勤負担を防止します。

@variable(model, 0 <= max_yakin <= 5)
for staff in eachrow(staff_data)
    s = staff.id
    if staff.目標夜勤日数 == ""
        @constraint(model, sum(x[s, d, SHIFT_TYPE[3]] for d in days_list) <= max_yakin)
    else
        @constraint(model, sum(x[s, d, SHIFT_TYPE[3]] for d in days_list) == staff.目標夜勤日数)
    end
end
  • max_yakin:最大夜勤回数を表す動的変数
  • 夜勤目標未指定スタッフは上限以下、指定スタッフは固定回数

2.10 日勤人数バランス制約

@variable(model, 8 <= max_nikkin <= 15)
@variable(model, 2 <= min_nikkin <= 10)
for d in days_list
    if ws[NIKKIN_TARGET_CELL][d] isa Number
        if ismissing(ws[NIKKIN_KYOYOCHI_CELL][d]) || ws[NIKKIN_KYOYOCHI_CELL][d]  == "以上"
            @constraint(model, sum(x[s, d, SHIFT_TYPE[2]] for s in staff_data.id) <= max_nikkin)
            @constraint(model, sum(x[s, d, SHIFT_TYPE[2]] for s in staff_data.id) >= ws[NIKKIN_TARGET_CELL][d])
        elseif ws[NIKKIN_KYOYOCHI_CELL][d] == "±0"
            @constraint(model, sum(x[s, d, SHIFT_TYPE[2]] for s in staff_data.id) == ws[NIKKIN_TARGET_CELL][d])
        end
    else 
        @constraint(model, sum(x[s, d, SHIFT_TYPE[2]] for s in staff_data.id) <= max_nikkin)
        @constraint(model, sum(x[s, d, SHIFT_TYPE[2]] for s in staff_data.id) >= min_nikkin)
    end
end
  • max_nikkin/min_nikkin:日勤上限・下限の変数
  • セルに「以上」「±0」「未入力」の3パターンで制約を切り替え
  • 土日は平日より必要人員を抑制できるよう、Excel上で少なめの要員数を指定し、モデルに反映します
  • 多忙が見込まれる日には増員を行う必要があるため、Excel上で多めの勤務者を設定し、最適化モデルに取り込みます
  • 通常日はスタッフ配置を平均化し、日ごとの業務負荷を均一化する

2.11 連続勤務制限

max = 5
for s in staff_data.id
    for d in max+1:days_list[end]
        @constraint(model, sum(x[s, d - h, k] for h in 0:max for k in SHIFT_TYPE[2:end]) <= max)
    end
end

2.11 目的関数と実行・結果出力

# 夜勤上限+日勤上限-日勤下限 を最小化
@objective(model, Min, max_yakin + max_nikkin - min_nikkin)

optimize!(model)

# ソルバー終了ステータスを出力
println("終了ステータス: ", termination_status(model))

# 配列にシフト結果を格納
result = Array{Symbol}(undef, staff_data.id[end], days_list[end])
for (s, d, k) in product(staff_data.id, days_list, SHIFT_TYPE)
    if value(x[s, d, k]) == 1
        result[s, d] = k
    end
end


# 日付を「1日」「2日」…のSymbolで命名してDataFrame化
df = DataFrame(result, Symbol.(string.(days_list) .* "日"))

# 「名前」列を追加し、列順を [名前, 1日, 2日, …] に調整
df[!, :名前] = Symbol.("看護師" .* string.(staff_data.id))
sorted_cols = [:名前]
append!(sorted_cols, [Symbol(string(day) .* "日") for day in sort(days_list)])
df = df[:, sorted_cols]
    
# 各勤務区分ごとの集計行を末尾に追加
for i in 1:length(SHIFT_TYPE)
    push!(df, Symbol.([count(==(SHIFT_TYPE[i]), df[!, col]) for col in names(df)]))
    df[nrow(df), :名前] = SHIFT_TYPE[i]
end

# 「休み」「夜勤」合計列を追加
df[:, "休み"] = [count(x ->  !ismissing(x) && (x == Symbol("休")), row[2:end]) for row in eachrow(df)]
df[:, "夜勤"] = [count(x ->  !ismissing(x) && (x == Symbol("夜")), row[2:end]) for row in eachrow(df)]

# Markdown形式の表として表示
pretty_table(df, backend = Val(:markdown))

3.実行例

終了ステータス: OPTIMAL

名前 1日 2日 3日 4日 5日 6日 7日 8日 9日 10日 11日 12日 13日 14日 15日 16日 17日 18日 19日 20日 21日 22日 23日 24日 25日 26日 27日 28日 29日 30日 31日 休み 夜勤
看護師1 12 2
看護師2 12 3
看護師3 12 4
看護師4 12 2
看護師5 12 3
看護師6 12 4
看護師7 12 3
看護師8 11 3
看護師9 12 3
看護師10 14 1
看護師11 12 4
看護師12 11 2
看護師13 11 3
看護師14 12 3
看護師15 13 2
看護師16 12 3
看護師17 12 3
看護師18 12 3
看護師19 12 0
看護師20 13 3
看護師21 12 0
看護師22 11 0
看護師23 13 1
看護師24 12 4
看護師25 12 3
8 8 8 8 8 14 14 8 8 9 8 8 14 14 10 8 8 8 9 14 11 8 14 8 8 8 14 12 8 8 8 0 0
12 12 12 12 12 7 7 12 12 11 12 12 7 7 11 12 12 12 11 7 10 12 7 12 12 12 7 9 12 12 12 0 0
2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 0 0
2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 0 0
1 1 1 1 1 0 0 1 1 1 1 1 0 0 0 1 1 1 1 0 0 1 0 1 1 1 0 0 1 1 1 0 0

4.まとめ・次回予告

今回は、以下の機能を実装しました。

  • Excel → DataFrame の読み込み機能
    シフト希望や日別要員数をそのままモデルに取り込めるようにしました。
  • 個別の勤務希望制約の反映
    スタッフごとのシフト希望(休み希望や特定日の勤務希望)をモデル化しました。

これらの機能により、実務データを元にした公平性の高い看護師シフト表が自動生成できるようになりました。

次回は以下の新機能をモデルに組み込みます。

  • 相性不一致ペアの夜勤回避機能
    相性が悪いスタッフ同士を同一の夜勤シフトに配置しない制約です。ペアでのトラブルを未然に防ぎ、安定した病棟運営を支援します。
  • 夜勤に女性看護師を必ず配置する機能
    夜勤シフトには必ず女性看護師を1名以上配置する制約です。女性看護師からのケアを希望する患者のニーズに応えます。
  • 連休推奨・非推奨機能でワークライフバランス強化
    連続した休暇取得をあらかじめ推奨・非推奨として設定できる機能です。

これらの業務要件を制約・目的関数に落とし込みます。これにより、現場の相性や性別・連休ニーズを反映した、より実践的なシフト生成を実現していきます。

関連記事


3
2
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
3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?