本記事では前回の続きとして、実際の疑似アニーリングのプログラム実装と実験を紹介する。
基本的な疑似アニーリングの仕組みは数独の記事で紹介したため割愛する。
※本記事にはAIで生成したコードが含まれます
3. 実装の流れ
実装の大きなステップは次の通り。
3.1 パラメータ設定
- 従業員数、日数、シフト種別、必要人数などを定義する
従業員5人・7日間・3シフトを想定
【コード】ShiftScheduler クラスの実装
class ShiftScheduler:
def __init__(self, num_employees=5, num_days=7):
"""
シフトスケジューリング問題のクラス
Parameters:
- num_employees: 従業員数
- num_days: スケジュール期間(日数)
"""
self.num_employees = num_employees
self.num_days = num_days
self.shift_types = ["Morning", "Evening", "Night"] # 朝、夕、夜
self.num_shifts = len(self.shift_types)
# 従業員名
self.employees = [f"Employee_{i+1}" for i in range(num_employees)]
# 各シフトの必要人数(最低要求)
self.min_required = {
"Morning": 2, # 朝シフトは最低2人
"Evening": 2, # 夕シフトは最低2人
"Night": 1 # 夜シフトは最低1人
}
# 従業員ごとの最大勤務日数
self.max_work_days = 5
3.2 制約の追加
制約をQUBOのペナルティ項に変換する。本記事では以下の制約を実装した。
- 各シフトの最低人数を満たす(必須)
- 1人1日1シフトまで(必須)
- 連続夜勤の禁止(推奨)
- 最大勤務日数の制限(推奨)
さらに、希望シフトが満たされた場合に報酬を与える項を追加している。
【コード】制約
def build_shift_bqm(self, preferences=None,
penalty_understaffed=10.0, # 人員不足ペナルティ
penalty_overwork=5.0, # 過労ペナルティ
penalty_consecutive=3.0, # 連続勤務ペナルティ
reward_preference=2.0): # 希望シフト報酬
"""
シフトスケジューリング問題のBQMを構築
"""
Q = defaultdict(float)
offset = 0.0
if preferences is None:
preferences = self.generate_sample_preferences()
# --- 制約1: 各シフトの最低人員確保 ---
for day in range(self.num_days):
for shift_idx, shift_name in enumerate(self.shift_types):
min_req = self.min_required[shift_name]
# 各シフトに配属される人数をカウント
shift_vars = [self.variable_key(emp_idx, day, shift_idx)
for emp_idx in range(self.num_employees)]
# (min_req - sum(x_i))^2 を最小化(人員不足を避ける)
offset += penalty_understaffed * (min_req ** 2)
# 線形項: -2 * min_req * sum(x_i)
for var in shift_vars:
Q[(var, var)] += -2.0 * penalty_understaffed * min_req
# 二次項: sum(x_i * x_j)
for i, var1 in enumerate(shift_vars):
Q[(var1, var1)] += penalty_understaffed
for j, var2 in enumerate(shift_vars):
if i < j:
Q[(var1, var2)] += 2.0 * penalty_understaffed
# --- 制約2: 従業員の過労防止(最大勤務日数制限)---
for emp_idx in range(self.num_employees):
work_vars = []
for day in range(self.num_days):
for shift_idx in range(self.num_shifts):
work_vars.append(self.variable_key(emp_idx, day, shift_idx))
# (sum(x_i) - max_work_days)^2 を最小化(ただし、超過分のみペナルティ)
# 簡略化: sum(x_i) が max_work_days を超えないように制約
for i, var1 in enumerate(work_vars):
for j, var2 in enumerate(work_vars):
if i < j:
Q[(var1, var2)] += penalty_overwork
# --- 制約3: 同一日に複数シフト禁止 ---
for emp_idx in range(self.num_employees):
for day in range(self.num_days):
day_vars = [self.variable_key(emp_idx, day, shift_idx)
for shift_idx in range(self.num_shifts)]
# 1日1シフトまで: (sum(x_i))^2 - sum(x_i) を最小化
for i, var1 in enumerate(day_vars):
Q[(var1, var1)] += -penalty_overwork
for j, var2 in enumerate(day_vars):
if i < j:
Q[(var1, var2)] += 2.0 * penalty_overwork
# --- 制約4: 連続夜勤の制限 ---
for emp_idx in range(self.num_employees):
for day in range(self.num_days - 1):
night_today = self.variable_key(emp_idx, day, 2) # 夜シフト = index 2
night_tomorrow = self.variable_key(emp_idx, day + 1, 2)
# 連続夜勤を避ける: x_i * x_{i+1} にペナルティ
Q[(night_today, night_tomorrow)] += penalty_consecutive
# --- 目的: 従業員の希望を満たす ---
for emp_idx, emp_name in enumerate(self.employees):
if emp_name in preferences:
for day, shift_idx in preferences[emp_name]:
var = self.variable_key(emp_idx, day, shift_idx)
Q[(var, var)] += -reward_preference # 希望シフトには負のバイアス
# BQM作成
bqm = dimod.BinaryQuadraticModel.from_qubo(dict(Q), offset=offset)
return bqm, preferences
-
1.2節 の二値変数 $x_{e,d,s}$ を
dimod.Binary
で作成
bqm = dimod.BinaryQuadraticModel.from_qubo(dict(Q), offset=offset)
3.3 OpenJijでサンプリング
- samplerで解を探索
【コード】OpenJijでサンプリング
def solve_shift_schedule(self, preferences=None, num_reads=500, sampler_type="sqa"):
"""
シフトスケジュールを最適化
"""
bqm, used_preferences = self.build_shift_bqm(preferences)
if sampler_type == "sqa":
sampler = oj.SQASampler()
else:
sampler = oj.SASampler()
batch_size = min(50, num_reads)
all_samples = None
for i in tqdm(range(0, num_reads, batch_size), desc="Optimizing Schedule"):
current_batch = min(batch_size, num_reads - i)
sampleset = sampler.sample(bqm, num_reads=current_batch)
if all_samples is None:
all_samples = sampleset
else:
all_samples = dimod.concatenate([all_samples, sampleset])
best_solution = all_samples.first.sample
return self.decode_solution(best_solution), all_samples.first.energy, used_preferences
def decode_solution(self, solution):
"""
QUBO解をシフトスケジュールに変換
"""
schedule = np.zeros((self.num_employees, self.num_days, self.num_shifts), dtype=int)
for emp_idx in range(self.num_employees):
for day in range(self.num_days):
for shift_idx in range(self.num_shifts):
var_key = self.variable_key(emp_idx, day, shift_idx)
if solution.get(var_key, 0) == 1:
schedule[emp_idx, day, shift_idx] = 1
return schedule
3.4 解の可視化・評価
- スケジュール表やヒートマップに変換
- 希望満足率や違反数を計算
4. 実装上の注意点
-
ペナルティ係数の調整が重要
- 人員不足の制約は重くする(絶対守るべき条件)
- 希望シフトは軽め(守れたら嬉しいくらい)
-
変数数が急増する問題
- $E \times D \times |S|$ のオーダーで変数が増える
- 大規模な場合は工夫や経験に基づく判断が必要
5. 実験と評価
5.1 評価指標
-
希望満足率
従業員 $e$ の希望集合を $P_e$ とし、満たされた希望の数を $s_e$ とする。
個人の満足率は次で表される:
$$
\text{Satisfaction}(e) = \frac{s_e}{|P_e|} \times 100 \quad [\%]
$$
全体の満足率は
$$
\text{Satisfaction}_{\text{overall}} = \frac{\sum_e s_e}{\sum_e |P_e|} \times 100
$$
-
人員不足違反
日 $d$、シフト $s$ に割り当てられた人数を $n_{d,s}$、必要人数を $m_s$ とする。
不足分は
$$
\text{Violation}_{d,s} = \max(0, m_s - n_{d,s})
$$
全体違反数は
$$
\text{Total Violations} = \sum_{d=1}^D \sum_{s \in S} \text{Violation}_{d,s}
$$
-
過労違反
従業員 $e$ の勤務回数は
$$
W_e = \sum_{d=1}^D \sum_{s \in S} x_{e,d,s}
$$
最大勤務日数 $M$ を超えた場合は過労違反とする:
$$
W_e > M \quad \Rightarrow \quad \text{Overwork Violation}
$$
-
連続夜勤違反 (Consecutive Night Shift Violation)
従業員 $e$ が日 $d$ と $d+1$ の両方で夜勤(シフト $s = \text{夜}$)に割り当てられた場合を違反とする:
\text{ConsecNight}_{e,d} =
\begin{cases}
1 & \text{if } x_{e,d,\text{夜}} = 1 \land x_{e,d+1,\text{夜}} = 1 \\
0 & \text{otherwise}
\end{cases}
全体の連続夜勤違反数は
$$
\text{Total ConsecNight Violations} = \sum_{e=1}^E \sum_{d=1}^{D-1} \text{ConsecNight}_{e,d}
$$
-
複数シフト違反 (Multiple Shift Assignment Violation)
1人の従業員 $e$ が同じ日 $d$ に複数のシフトへ割り当てられた場合を違反とする:
\text{MultiShift}_{e,d} =
\begin{cases}
1 & \text{if } \sum_{s \in S} x_{e,d,s} > 1 \\
0 & \text{otherwise}
\end{cases}
全体の複数シフト違反数は
$$
\text{Total MultiShift Violations} = \sum_{e=1}^E \sum_{d=1}^D \text{MultiShift}_{e,d}
$$
5.2 実験結果の例
サンプルスケジュールとして5人のシフト希望をランダムで生成し、それに基づいたシフトスケジューリングの一連の流れを視覚化したものを以下に示す。
▼シフト希望(今回夜の希望が多いというパターンで生成された)
今回のサンプルスケジュールに基づき、以下の評価を行った。
-
希望満足率
- 個別満足率:
- Employee_1: $44.4\%$
- Employee_2: $57.1\%$
- Employee_3: $57.1\%$
- Employee_4: $66.7\%$
- Employee_5: $44.4\%$
- 全体満足率: 約 $51.4\%$
- 個別満足率:
-
人員不足違反
- 各日の分析結果より、ほぼすべての日で「朝」「夕」に不足が見られた。
- 特に木曜の夜、土曜の夜は割当が $0$ 名であり、重大な不足といえる。
- 1週間あたりの違反総数は 15件 であった。
-
過労違反
- 各従業員の勤務回数は均等に $4$ 回であり、最大勤務日数制約($M=5$ などと仮定)を超過しなかったため、過労違反は発生しなかった。
-
夜勤の連続割当
- 各従業員に夜勤は分散されており、連続割当は見られなかった。
ヒートマップ可視化では、行を従業員、列を日付に対応させ、色でシフト状態を示すことで不足状況が直感的に把握できた。
6. まとめ
本実験では、シフトスケジュールの自動生成結果を評価指標に基づき分析した。
希望満足率は約 $51\%$ と半数程度が希望通りに割り当てられたが、依然として人員不足が多く、特に夜勤の割当が欠落する日があるなど実務上の課題が残った。
一方で勤務回数の偏りは見られず、過労違反や夜勤連続といった制約違反は回避できていた。
今後は、
- 人員不足をより重視した目的関数の改良
- 希望満足率とのトレードオフ調整
- 実際の運用条件(法規制・勤務パターン制約)への適用
などを通じて、より実用的なスケジューリングが可能になると考えられる。