救急車のサイレンが近づくたび、トリアージナースの表情が少しだけ曇る瞬間を想像してみてください。
ER(救急外来)の待合室における「待ち時間」は、単なる不便さの問題ではありません。それは時に、患者の予後を左右し、医療スタッフの燃え尽き症候群(バーンアウト)を引き起こす深刻な社会課題です。「救急は水物(みずもの)」——いつ、どんな患者が来るか分からないから、対策の打ちようがない。長年、現場ではそう諦められてきました。
しかし、データサイエンスの視点でその「不確実性」を分解してみると、そこには明確なパターンと、制御可能な変数が隠れていることに気づきます。
本記事では、AIコンサルタントの視点から、データサイエンスを用いてこの課題にアプローチする方法を解説します。
予測モデルの開発において、「予測はあくまで手段であり、ゴールは意思決定の最適化である」という考え方は非常に重要です。今回はこのアプローチを、医療現場という最もクリティカルな領域に適用してみましょう。
本記事では、Pythonを用いて以下のシステムをゼロから構築するプロセスを共有します。
- 時系列データ解析: 過去のトレンドから未来の来院数を予測する。
- 数理最適化: 予測された需要に対し、最小のコストで最大のカバー率を実現するスタッフ配置を計算する。
単に「AIで予測してみました」で終わるのではなく、その予測を使って「明日、誰を何人配置すべきか」という具体的なアクションプランを導き出すことが、実務におけるAI活用の本来の目的です。
コードエディタを開いてください。ERの混沌とした現場に、ロジックという名の秩序をもたらしましょう。
1. ミッション:ERの「見えない混雑」をデータで可視化する
救急医療の現場が抱える課題は、需要と供給のミスマッチに尽きます。しかし、このミスマッチは常にランダムに発生しているわけではありません。
なぜ救急外来は予測が難しいのか
一般的に、ERの来院パターンは複雑です。インフルエンザの流行期、猛暑による熱中症、連休中の事故増加、そして地域固有のイベント。これらが複合的に絡み合い、需要のスパイク(急増)を生み出します。
多くの病院では、経験豊富な師長や管理者が「勘と経験」でシフトを組んでいます。「去年のこの時期は忙しかったから、少し多めに人を入れよう」といった具合です。しかし、この人間的なアプローチには限界があります。バイアスがかかりやすく、急な変動に対応しきれないからです。
ここで目指すのは、この「暗黙知」を「形式知」へ、さらには「アルゴリズム」へと昇華させることです。
本チュートリアルのゴール設定
今回は、中規模病院の救急外来を想定したシナリオを設定します。
- 入力: 過去2年間の来院データ(日時、天候、来院数など)
- 予測モデル: 次の1週間の時間帯別来院数を予測(ターゲット精度:RMSE 2.0以下)
- 最適化モデル: 予測された来院数に基づき、必要な医師・看護師のシフト表を自動生成
- 制約条件: スタッフの連続勤務制限、最低配置人数などの労務規定
使用する技術スタック
今回は以下のPythonライブラリを使用します。環境構築がまだの方は、pip installしておいてください。
- Pandas / NumPy: データ操作と数値計算の基盤
- Scikit-learn: 機械学習モデル(Random Forest)の構築
- PuLP: 線形計画法による数理最適化
- Matplotlib / Seaborn: データの可視化
特にPuLPは、エンジニアには馴染みが薄いかもしれませんが、ビジネス課題を解決するための非常に強力な武器になります。予測モデルが「未来」を示す羅針盤なら、最適化モデルはそこへ至る「最短ルート」を示す地図のようなものです。
2. データ準備:リアルなER稼働状況を模したダミーデータの生成
医療データは個人情報の塊であり、実データを記事で公開することはできません。そこで、統計的な特性を模倣した「合成データ(Synthetic Data)」を生成します。これは実際のプロジェクトでも、プライバシー保護やアルゴリズムの初期検証において非常に有効なアプローチです。
必要な特徴量の設計
ERの来院数に影響を与える因子を考えてみましょう。
- 時間的要因: 時間帯(昼間/夜間)、曜日、月、祝日フラグ
- 季節的要因: 冬場の感染症増加、夏場の熱中症
- 突発的要因: 天候、近隣イベント(今回はシンプルにするためノイズとして扱います)
NumPyとPandasによる時系列データの生成
ポアソン分布(Poisson Distribution)を用いて、ランダムながらも一定の傾向を持つ来院データを生成します。ポアソン分布は、「ある期間内に平均λ回起こる事象が、その期間内にk回起こる確率」を表すのによく使われ、来客数や事故件数のモデリングに適しています。
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
# 再現性のためにシードを固定
np.random.seed(42)
def generate_er_data(start_date, end_date):
# 1時間ごとのタイムスタンプを作成
dates = pd.date_range(start=start_date, end=end_date, freq='H')
df = pd.DataFrame(dates, columns=['ds'])
# 特徴量の生成
df['hour'] = df['ds'].dt.hour
df['dayofweek'] = df['ds'].dt.dayofweek
df['month'] = df['ds'].dt.month
# ベースとなる来院数(時間帯による変動)
# 夜間は少なく、日中から夕方にかけて増える傾向を模擬
# np.sinを使って波を作る簡易的なロジック
base_demand = 3 + 2 * np.sin((df['hour'] - 8) * np.pi / 12)
# 週末の増加傾向(金曜夜〜土曜)
weekend_effect = np.where(df['dayofweek'] >= 4, 1.5, 0)
# 季節性(冬場12-2月はインフルエンザ等で増加)
seasonality = np.where(df['month'].isin([12, 1, 2]), 2.0, 0)
# 期待値(lambda)の計算
# ベース + 週末効果 + 季節性 + ランダムノイズを防ぐための最低値保証
lambda_val = np.maximum(0.5, base_demand + weekend_effect + seasonality)
# ポアソン分布からサンプリングして実際の来院数を生成
df['y'] = np.random.poisson(lambda_val)
return df
# 2年分のデータを生成
df = generate_er_data('2022-01-01', '2023-12-31')
# データの確認
print(df.head())
print(f"Total records: {len(df)}")
データの前処理と探索的データ分析(EDA)
生成されたデータを見てみましょう。実務ではここで異常値のチェックや欠損値補完を行いますが、今回はクリーンなデータが生成されています。
重要なのは、データのトレンドを目で見て確認することです。「夜中の3時に来院数がピークになっている」ような不自然なデータでは、どんな高度なモデルも役に立ちません。
# 週ごとの平均来院数をプロットしてトレンド確認
plt.figure(figsize=(12, 6))
df.set_index('ds')['y'].resample('W').mean().plot()
plt.title('Weekly Average ER Visits')
plt.ylabel('Average Visits per Hour')
plt.show()
# 時間帯別の平均来院数
plt.figure(figsize=(10, 5))
df.groupby('hour')['y'].mean().plot(kind='bar')
plt.title('Average Visits by Hour')
plt.ylabel('Visits')
plt.show()
このグラフから、夕方18時〜20時頃にピークが来ていることや、冬場に全体のベースラインが上がっていることが確認できれば、データ生成は成功です。
3. 予測フェーズ:来院数を予測する回帰モデルの構築
データが整ったら、次は予測モデルの構築です。今回は扱いやすく、解釈性が高いランダムフォレスト(Random Forest)を使用します。時系列専用のモデル(ARIMAやProphet)も強力ですが、カレンダー情報や天候などの外部変数を柔軟に取り込みたい場合、木ベースのモデルは非常に強力なベースラインになります。
過去の来院データをどう学習させるか
時系列データを機械学習モデル(ML)で扱う際の最大のポイントは、ラグ特徴量(Lag Features)の作成です。「1週間前の同じ時間の来院数」や「過去24時間の平均来院数」といった情報を特徴量としてモデルに与えることで、過去の文脈を学習させることができます。
from sklearn.ensemble import RandomForestRegressor
from sklearn.metrics import mean_squared_error, mean_absolute_error
from sklearn.model_selection import TimeSeriesSplit
def create_features(df):
df = df.copy()
# ラグ特徴量:24時間前(前日同時間)、168時間前(前週同時間)
df['lag_24h'] = df['y'].shift(24)
df['lag_168h'] = df['y'].shift(168)
# 移動平均:過去24時間の平均
df['rolling_mean_24h'] = df['y'].shift(1).rolling(window=24).mean()
# 欠損値(シフト操作で発生した初期データ)を削除
df = df.dropna()
return df
# 特徴量エンジニアリング
df_features = create_features(df)
# 学習データとテストデータの分割(時系列なのでシャッフルしないこと!)
# ラスト1週間をテストデータとする
test_size = 24 * 7
train = df_features.iloc[:-test_size]
test = df_features.iloc[-test_size:]
features = ['hour', 'dayofweek', 'month', 'lag_24h', 'lag_168h', 'rolling_mean_24h']
target = 'y'
# モデル構築
model = RandomForestRegressor(n_estimators=100, random_state=42, n_jobs=-1)
model.fit(train[features], train[target])
# 予測
predictions = model.predict(test[features])
# 評価
rmse = np.sqrt(mean_squared_error(test[target], predictions))
mae = mean_absolute_error(test[target], predictions)
print(f'RMSE: {rmse:.4f}')
print(f'MAE: {mae:.4f}')
RMSEとMAEによる評価指標の選定
ここではRMSE(二乗平均平方根誤差)とMAE(平均絶対誤差)を見ています。ERの現場感覚で言えば、MAEが重要です。「平均して何人ズレているか」が直感的に分かるからです。
例えば、MAEが1.5であれば、「予測は平均して1.5人程度の誤差がある」ことになります。この誤差を許容できるかどうかが、次の最適化フェーズでの安全係数(バッファ)の設定に関わってきます。
過学習を防ぐための交差検証
時系列データでは、通常のK-Fold交差検証は使えません(未来のデータで過去を訓練することになるため)。TimeSeriesSplitを使用して、時間を輪切りにしながら検証を行うのが定石です。今回は簡易化のためホールドアウト検証(ラスト1週間をテスト)を行いましたが、実運用モデル開発では必ず時系列CVを行ってください。
4. 最適化フェーズ:予測に基づきスタッフ配置を自動計算する
ここからが本記事のハイライトです。予測された「来院数」という数値を、「明日誰を出勤させるか」という意思決定に変換します。
ここではPuLPライブラリを使った数理最適化(Linear Programming / Integer Programming)を行います。問題をシンプルにするため、以下の設定で解いてみましょう。
- 目的: 人件費(スタッフ稼働時間)の最小化
- 制約1: 各時間帯において、予測患者数に対応できる十分なスタッフ数を確保する(例: 患者3人につきスタッフ1名必要)
- 制約2: シフトパターンは「日勤(8-16時)」「準夜勤(16-24時)」「深夜勤(0-8時)」の3パターンのみ
数理最適化とは?PuLPライブラリの導入
数理最適化とは、与えられた制約条件の中で、目的関数(コストや時間など)を最小化または最大化する変数の値を求める数学的手法です。
import pulp
# 予測結果をデータフレームに格納(テスト期間のデータを使用)
shifts_df = test.copy()
shifts_df['predicted_demand'] = predictions
# 必要スタッフ数の算出(単純化:患者3人につき1スタッフ + 安全バッファ1名)
shifts_df['required_staff'] = np.ceil(shifts_df['predicted_demand'] / 3).astype(int) + 1
# 最適化問題の定義
prob = pulp.LpProblem("ER_Staff_Scheduling", pulp.LpMinimize)
# 日付リスト
dates = shifts_df['ds'].dt.date.unique()
# 変数定義:各日の各シフト(Day, Evening, Night)に割り当てるスタッフ数
# 変数は非負の整数(Integer)
shift_types = ['Day', 'Evening', 'Night']
staff_vars = pulp.LpVariable.dicts("Staff",
((d, s) for d in dates for s in shift_types),
lowBound=0,
cat='Integer')
# コスト定義(目的関数):ここでは全シフトの合計人数を最小化
prob += pulp.lpSum([staff_vars[d, s] for d in dates for s in shift_types])
# 制約条件の追加
for d in dates:
# その日のデータを抽出
daily_data = shifts_df[shifts_df['ds'].dt.date == d]
for _, row in daily_data.iterrows():
hour = row['hour']
required = row['required_staff']
# 各時間帯で稼働しているシフトパターンを特定して合計
# 日勤(Day): 8-16時
# 準夜(Evening): 16-24時
# 深夜(Night): 0-8時
current_staff = 0
if 8 <= hour < 16:
current_staff += staff_vars[d, 'Day']
elif 16 <= hour <= 23: # 24時は0時として扱うため23時まで
current_staff += staff_vars[d, 'Evening']
elif 0 <= hour < 8:
current_staff += staff_vars[d, 'Night']
# 制約:稼働スタッフ数 >= 必要スタッフ数
prob += current_staff >= required
# ソルバー実行
status = prob.solve()
print(f"Status: {pulp.LpStatus[status]}")
# 結果の表示(最初の3日間)
for d in dates[:3]:
print(f"--- {d} ---")
for s in shift_types:
print(f"{s}: {pulp.value(staff_vars[d, s])} players")
制約条件の定義と「解けない」場合の緩和策
上記のコードは非常にシンプルですが、数理最適化の骨格を表しています。
prob += current_staff >= required という行が核心です。これは「その時間に働いている人の合計数は、予測された必要人数以上でなければならない」という制約を数式化したものです。
実務では、これに加えて「各スタッフの希望休」や「専門医の必須配置」などの複雑な制約が加わります。制約がきつすぎると Status: Infeasible(実行不可能)となり、解が見つからないことがあります。その場合は、制約を少し緩める(緩和問題)か、コストがかかっても外部リソース(スポットバイトの医師など)を利用できるような変数を追加する必要があります。
5. 現場導入シミュレーション:モデルの継続的改善と運用
モデルが完成しても、それはプロジェクトの終わりの始まりに過ぎません。医療現場という、ミスが許されない環境にAIを導入するには、技術以上の配慮が必要です。
Model Drift(モデルの劣化)への対応
ERのトレンドは変化します。新しい感染症の流行、近隣病院の閉鎖、地域の人口動態の変化。これらはモデルが学習していないパターンかもしれません。
これを「Model Drift」と呼びます。
実務において推奨される運用フローは以下の通りです。
- 週次での再学習: 毎週、直近のデータを加えてモデルを再学習させる。
- 予実管理モニタリング: 予測と実績の乖離(MAE)を常に監視し、一定の閾値を超えたらアラートを出す。
- Human-in-the-Loop: AIが出したシフト案をそのまま採用するのではなく、最終的に師長が確認・修正するプロセスを必ず挟む。
現場スタッフへのフィードバックループ
現場の医師や看護師にとって、AIは「仕事を奪うもの」でも「魔法の杖」でもなく、「信頼できる同僚」であるべきです。
予測が外れたときは、なぜ外れたのかを現場と共有しましょう。「昨日は近くで大規模な祭りがあったため、予測より20%多かった」といった分析を共有することで、現場の信頼を獲得できます。また、現場からの「来週は近くの小学校で運動会があるから、怪我人が増えるかも」といった定性情報をモデルの特徴量(イベントフラグ)として組み込むことで、モデルはより賢くなります。
まとめ
ERの混雑予測とスタッフ配置最適化は、決して夢物語ではありません。今回紹介したPythonコード——Scikit-learnによる予測と、PuLPによる最適化——は、その第一歩となる具体的なツールです。
- 予測: 過去のデータからパターンを見つけ出し、不確実性を減らす。
- 最適化: 予測された未来に対して、最も合理的で効率的なリソース配分を決定する。
この2つを組み合わせることで、「待ち時間」という見えない課題に対処することが可能になります。
もちろん、実際の導入には電子カルテシステムとの連携や、より複雑なシフト制約への対応など、乗り越えるべきハードルはいくつもあります。しかし、まずは手元のデータで小さなプロトタイプを作ってみてください。画面上のグラフと数字が、現場の誰かの負担を少しでも減らす可能性を感じられるはずです。
データサイエンスを活用し、医療現場の業務プロセスを最適化していくための第一歩として、ぜひ参考にしていただければ幸いです。
コメント