はじめに
「日常の疑問をシミュレーションで考えるシリーズのまとめ」において、日常の行動を数理モデルを作って考えてきましたが、今回はスーパーやコンビニのレジ待ち時間についてシミュレーションを行いました。
記事もシミュレータも簡単な指示だけして、ほとんどLLMに書かせています。
スーパーやコンビニでレジに並ぶとき、
- 「1列に並んで空いたレジへ進む方式」
- 「各レジごとに列が分かれている方式」
があります。
体感的には、
「自分の列だけ異常に進まない」
という“レジ運”を感じることがあります。
そこで今回は、
「レジは1列に並ぶ方が本当に効率的なのか?」
を、Pythonで簡単な待ち行列シミュレーションを作って検証してみます。
今回は、
- 客の到着
- 会計時間
- 長時間かかる「事故客」
などを乱数で生成し、モンテカルロ的に比較してみます。
モデル化
今回は次のような単純化したモデルを考えます。
客の到着
客はランダムに到着するとします。
到着間隔は指数分布で生成します。
平均30秒ごとに1人来店
としました。
会計時間
会計時間もランダムとします。
通常客は平均60秒程度で会計を終えます。
ただし現実には、
- 小銭で時間がかかる
- クーポンを使う
- 公共料金支払い
- 電子決済エラー
など、「長時間客」が存在します。
そこで今回は、
5%の確率で会計時間が5倍になる
ようにしました。
固定列方式
各レジごとに列が分かれている方式です。
客は、
「並んでいる人数」
だけを見て、一番短い列へ並びます。
重要なのは、
客は未来の会計時間を知らない
という点です。
つまり、
「1人しか並んでいないから速そう」
と思って並んだ結果、その客が長時間客だった、という状況が発生します。
1列方式
全員が共通の1列に並び、空いたレジへ順番に案内される方式です。
この方式では、
- 特定レジへの偏り
- 長時間客による事故
が全体へ平均化されます。
シミュレーション結果
実際に5000人分の客をシミュレーションしてみました。
条件:
- レジ数: 3
- 客到着平均: 30秒
- 会計平均時間: 60秒
- 長時間客: 5%
です。
結果例
================================
固定列方式
================================
平均待ち時間 : 124.30 sec
最大待ち時間 : 2399.67 sec
95パーセンタイル : 543.68 sec
================================
1列方式
================================
平均待ち時間 : 106.32 sec
最大待ち時間 : 1129.23 sec
95パーセンタイル : 443.86 sec
(乱数によって多少変動します)
ヒストグラム
待ち時間分布を比較すると、
-
固定列方式
→ 長時間待ちが多い -
1列方式
→ 極端な待ちが減る
という傾向が見えます。
特に、
「最大待ち時間」
や
「95パーセンタイル」
の差が大きくなりました。
つまり、
「平均」よりも「ハズレの少なさ」
が、1列方式の大きなメリットと言えそうです。
まとめ
今回の簡単なシミュレーションでは、
- 1列方式の方が待ち時間のばらつきが小さい
- 「レジ運」が発生しにくい
- 長時間客の影響を平均化できる
という結果になりました。
特に、
「会計時間のばらつき」
が大きいほど、1列方式が有利になるようです。
逆に、
- 全員ほぼ同じ会計時間
- セルフレジ中心
のような状況では、差は小さくなるかもしれません。
おわりに
今回はかなり単純化したモデルでしたが、
- 列変更可能
- 商品数を見て列選択
- セルフレジ
- 高齢者割合
- 電子決済トラブル
などを追加すると、さらに現実っぽいシミュレーションができそうです。
「なんとなく感じる日常の不公平感」をシミュレーションで可視化すると、意外と面白いですね。
他の記事も興味があったら見てみてください。
コード
import numpy as np
import matplotlib.pyplot as plt
from collections import deque
# ==========================================
# パラメータ
# ==========================================
NUM_CUSTOMERS = 5000
NUM_REGISTERS = 3
ARRIVAL_MEAN = 30.0 # 客到着間隔平均 [sec]
SERVICE_MEAN = 60.0 # 会計時間平均 [sec]
HEAVY_PROB = 0.05 # 長時間客確率
HEAVY_SCALE = 5.0 # 長時間倍率
np.random.seed(0)
# ==========================================
# 客データ生成
# ==========================================
arrival_intervals = np.random.exponential(
ARRIVAL_MEAN,
NUM_CUSTOMERS
)
arrival_times = np.cumsum(arrival_intervals)
service_times = np.random.exponential(
SERVICE_MEAN,
NUM_CUSTOMERS
)
# 長時間客を混ぜる
heavy_mask = np.random.rand(NUM_CUSTOMERS) < HEAVY_PROB
service_times[heavy_mask] *= HEAVY_SCALE
# ==========================================
# 固定列方式
# ==========================================
def simulate_fixed_queue(arrivals, services, num_registers):
# 各レジ:
# dequeには「未来の終了時刻」が入る
registers = [
deque()
for _ in range(num_registers)
]
wait_times = []
for arrival, service in zip(arrivals, services):
# ----------------------------------
# 過去終了分を削除
# ----------------------------------
for q in registers:
while q and q[0] <= arrival:
q.popleft()
# ----------------------------------
# 一番人数少ない列を選択
# ----------------------------------
queue_lengths = [
len(q)
for q in registers
]
idx = np.argmin(queue_lengths)
q = registers[idx]
# ----------------------------------
# 会計開始時刻
# ----------------------------------
if len(q) == 0:
start_time = arrival
else:
last_finish = q[-1]
start_time = max(
arrival,
last_finish
)
# ----------------------------------
# 終了時刻
# ----------------------------------
finish_time = start_time + service
q.append(finish_time)
# ----------------------------------
# 待ち時間記録
# ----------------------------------
wait_time = start_time - arrival
wait_times.append(wait_time)
return np.array(wait_times)
# ==========================================
# 1列方式
# ==========================================
def simulate_single_queue(arrivals, services, num_registers):
# 各レジの終了予定時刻
register_finish_times = np.zeros(num_registers)
wait_times = []
for arrival, service in zip(arrivals, services):
# ----------------------------------
# 最初に空くレジ
# ----------------------------------
idx = np.argmin(register_finish_times)
# ----------------------------------
# 会計開始
# ----------------------------------
start_time = max(
arrival,
register_finish_times[idx]
)
finish_time = start_time + service
register_finish_times[idx] = finish_time
# ----------------------------------
# 待ち時間
# ----------------------------------
wait_time = start_time - arrival
wait_times.append(wait_time)
return np.array(wait_times)
# ==========================================
# 実行
# ==========================================
wait_fixed = simulate_fixed_queue(
arrival_times,
service_times,
NUM_REGISTERS
)
wait_single = simulate_single_queue(
arrival_times,
service_times,
NUM_REGISTERS
)
# ==========================================
# 結果表示
# ==========================================
print("================================")
print("固定列方式")
print("================================")
print(f"平均待ち時間 : {wait_fixed.mean():.2f} sec")
print(f"最大待ち時間 : {wait_fixed.max():.2f} sec")
print(f"95パーセンタイル : {np.percentile(wait_fixed, 95):.2f} sec")
print()
print("================================")
print("1列方式")
print("================================")
print(f"平均待ち時間 : {wait_single.mean():.2f} sec")
print(f"最大待ち時間 : {wait_single.max():.2f} sec")
print(f"95パーセンタイル : {np.percentile(wait_single, 95):.2f} sec")
# ==========================================
# ヒストグラム
# ==========================================
plt.figure(figsize=(10, 5))
plt.hist(
wait_fixed,
bins=60,
alpha=0.5,
label="Fixed Queue"
)
plt.hist(
wait_single,
bins=60,
alpha=0.5,
label="Single Queue"
)
plt.xlabel("Wait Time [sec]")
plt.ylabel("Count")
plt.title("Checkout Queue Simulation")
plt.legend()
plt.tight_layout()
plt.show()