Mímisbrunnr知恵の泉

← オペレーションズマネジメント 一覧

🎓 レベル:基礎 | 重要度:A(必須)

📎 前提:プロセス分析とリトルの法則(フローの土台) | 成分分解の理論:時系列データと分解(時系列分析) | 次:指数平滑と季節指数による需要予測

要点(BLUF)

1. 需要予測はオペレーションズの入力である

OM で需要予測を学ぶ理由は、予測の精度自慢のためではありません。予測はその先の意思決定の入力だからです——何個発注するか(第3章 在庫)、何人配置し何台動かすか(第5章 生産計画)、設備をいつ増やすか(キャパシティ)。予測が変われば在庫も人員も変わります。

flowchart LR
  D["需要データ(履歴)"] --> M["平滑・分解<br/>(水準/トレンド/季節)"]
  M --> F["予測 F_t"]
  F --> INV["在庫・発注(第3章)"]
  F --> CAP["キャパ・集約計画(第5章)"]
  F --> ERR["予測誤差の監視<br/>(02-03)"]
  ERR --> SS["安全在庫 SS=z σ_e(第3章)"]

意思決定の時間軸ごとに、向く手法が変わります。

時間軸代表的な意思決定向く手法
短期(日〜数週)発注・要員シフト・スケジューリング移動平均・指数平滑
中期(数ヶ月〜1年)集約計画(生産平準化・在庫積み増し)季節指数法・Holt-Winters
長期(1年〜)設備投資・キャパシティ・新規拠点回帰・市場モデル・定性予測

OM で繰り返し効く需要予測の3原則を先に押さえます。

  1. 予測は必ず外れる。点予測だけでなく誤差の幅を持たねば意思決定に使えません(だから誤差を測り → 予測誤差の評価と追跡信号、安全在庫で吸収 → 第3章)。
  2. 集約するほど当たる。個々のSKU・店舗より、束ねた合計のほうが相対誤差が小さい(リスクプーリング。第3章・第6章 SCM で定量化)。
  3. 近い未来ほど当たる。ホライズンが延びるほど精度は落ちる。長期予測に短期の精度を期待しない。

2. 需要の構造:水準・トレンド・季節・ノイズ

予測の前に、需要系列がどんな成分でできているかを見ます。基本は加法の重ね合わせ、

yt=Lt水準+Ttトレンド+St季節+εtノイズy_t = \underbrace{L_t}_{\text{水準}} + \underbrace{T_t}_{\text{トレンド}} + \underbrace{S_t}_{\text{季節}} + \underbrace{\varepsilon_t}_{\text{ノイズ}}

変動の大きさが水準に比例して伸びるなら乗法 yt=Lt×St×εty_t = L_t \times S_t \times \varepsilon_t を使います(対数を取れば加法に戻る)。この成分分解の理論・加法/乗法の使い分け・分解アルゴリズムは 時系列データと分解 に詳説があります。本稿では「需要はこの4成分でできている」という見方だけを使い、移動平均が拾えるのは水準まで——トレンド・季節は別の手当て(指数平滑と季節指数による需要予測)が要る、という線を引きます。

3. 移動平均:数式と「遅れ」の理屈

単純移動平均 SMA:直近 kk 期の単純平均を、次期 t+1t+1 の予測 Ft+1F_{t+1} とします。

Ft+1=1ki=0k1ytiF_{t+1} = \frac{1}{k}\sum_{i=0}^{k-1} y_{t-i}

加重移動平均 WMA:直近を重く、過去を軽く。重みは合計1に正規化します(w0w_0 が最新)。

Ft+1=i=0k1wiyti,i=0k1wi=1F_{t+1} = \sum_{i=0}^{k-1} w_i\, y_{t-i},\qquad \sum_{i=0}^{k-1} w_i = 1

どちらも次期予測はフラット(その時点の平滑水準をそのまま横に伸ばすだけ)で、トレンドや季節を将来に外挿しません。ここが移動平均の限界です。

なぜトレンドに「遅れる」のか

需要が一定の傾き bb で増えている(ys=a+bsy_s = a + b\,s)とき、SMA がどれだけ下振れするかは計算できます。直近 kk 期の平均は、その期間の真ん中の時刻 tk12t-\tfrac{k-1}{2} の水準に等しいので、

Ft+1=a+b(tk12)F_{t+1} = a + b\Big(t - \frac{k-1}{2}\Big)

一方、実現する yt+1=a+b(t+1)y_{t+1} = a + b(t+1)。差し引くと、予測の**系統的な下振れ(遅れ)**は

yt+1Ft+1=b(1+k12)=bk+12y_{t+1} - F_{t+1} = b\Big(1 + \frac{k-1}{2}\Big) = b\,\frac{k+1}{2}

kk に比例して遅れが大きくなるのがポイントです。WMA で直近を重くすると、平均の「重心」が新しい時刻へ寄るので、同じ窓でも遅れが小さくなります(重心の遅れ iwii\sum_i w_i\, i が小さくなるため)。次のコードでこの理屈を数値で確かめます。

4. SMA・WMA を当てる:平滑度と遅れ(コード)

水準+トレンド+季節+ノイズに、第18月に一度だけ水準が +40 跳ねるステップ変化を仕込んだ36ヶ月の擬似需要に、SMA(3)・SMA(12)・WMA(直近3期, 重み0.5/0.3/0.2) を当てます。平滑度(平滑後系列の1階差の標準偏差。小さいほど滑らか)と、遅れ(1期先予測の平均誤差。大きいほどトレンドに遅れる)、ステップ応答(純粋なステップへの追従の速さ)を測ります。

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import japanize_matplotlib

rng = np.random.default_rng(42)

# 擬似月次需要(36ヶ月):水準 + 線形トレンド + 月次季節 + ノイズ。
# さらに第18月に一度だけ水準が +40 跳ねる「ステップ変化」を仕込む
n = 36
t = np.arange(n)
level, slope, samp = 200.0, 2.0, 15.0
season = samp * np.sin(2 * np.pi * (t % 12) / 12.0)   # 季節(振幅15)
step = np.where(t >= 18, 40.0, 0.0)                   # 第18月の水準ジャンプ
signal = level + slope * t + season + step            # ノイズなしの真の信号
y = signal + rng.normal(0, 8.0, n)                    # 観測(ノイズSD=8)
demand = pd.Series(y, name="需要")

# 移動平均:単純SMA(3)・SMA(12)・加重WMA(直近3期, 重み0.5/0.3/0.2)
sma3 = demand.rolling(3).mean()
sma12 = demand.rolling(12).mean()
w = np.array([0.5, 0.3, 0.2])                          # 直近→過去の重み(合計1)
wma = demand.rolling(3).apply(lambda x: np.dot(x[::-1], w), raw=True)

# (1) 平滑度=平滑後系列の1階差の標準偏差(小さいほど滑らか)
def diff_sd(s):
    return s.dropna().diff().dropna().std(ddof=1)

print("=== 平滑度(1階差の標準偏差・小さいほど滑らか)===")
print(f"  原系列 raw : {diff_sd(demand):6.2f}")
print(f"  WMA(3)     : {diff_sd(wma):6.2f}")
print(f"  SMA(3)     : {diff_sd(sma3):6.2f}")
print(f"  SMA(12)    : {diff_sd(sma12):6.2f}")

# (2) トレンドへの遅れ=1期先予測の平均誤差(符号つき)。
#     F_{t+1}=時刻tの移動平均。トレンド上昇局面では予測が下振れ(正の誤差)
def mean_signed_error(sm):
    f = sm.shift(1)                  # 時刻t-1の平滑値が時刻tの予測
    e = demand - f
    return e.dropna().mean()

print("\n=== トレンドへの遅れ(1期先予測の平均誤差・大きいほど遅れる)===")
print(f"  WMA(3)  : {mean_signed_error(wma):6.2f}")
print(f"  SMA(3)  : {mean_signed_error(sma3):6.2f}")
print(f"  SMA(12) : {mean_signed_error(sma12):6.2f}")

# (3) ステップ応答:ノイズ・季節を除いた純粋なステップ(100->140)で、
#     新水準の90%(=136)に到達するまでの月数を測る
pure = np.where(np.arange(40) >= 5, 140.0, 100.0)
ps = pd.Series(pure)
def periods_to_90(window):
    sm = ps.rolling(window).mean()
    reached = np.where(sm.values >= 136.0)[0]
    return reached[0] - 5            # ステップ発生(=月5)からの経過

print("\n=== ステップ応答(100->140、新水準の90%=136 到達までの月数) ===")
print(f"  SMA(3)  : {periods_to_90(3)} ヶ月")
print(f"  SMA(12) : {periods_to_90(12)} ヶ月")

# 図:原系列と3つの平滑(ステップ第18月)
plt.figure(figsize=(11, 5))
plt.plot(t, demand, "o-", color="0.6", lw=1, ms=4, label="原系列(観測需要)")
plt.plot(t, sma3, color="#1f77b4", lw=2, label="SMA(3) 反応速い・粗い")
plt.plot(t, wma, color="#2ca02c", lw=2, label="WMA(3) 直近重視")
plt.plot(t, sma12, color="#d62728", lw=2, label="SMA(12) 滑らか・遅れ大")
plt.axvline(18, ls=":", color="gray")
plt.text(18.3, demand.min(), "第18月:水準ジャンプ", color="gray")
plt.xlabel("月"); plt.ylabel("需要")
plt.title("移動平均:窓が大きいほど滑らか、だがトレンド・ステップに遅れる")
plt.legend(); plt.tight_layout(); plt.show()

出力:

=== 平滑度(1階差の標準偏差・小さいほど滑らか)===
  原系列 raw :  13.77
  WMA(3)     :   7.19
  SMA(3)     :   6.45
  SMA(12)    :   1.77

=== トレンドへの遅れ(1期先予測の平均誤差・大きいほど遅れる)===
  WMA(3)  :   4.68
  SMA(3)  :   5.51
  SMA(12) :  24.68

=== ステップ応答(100->140、新水準の90%=136 到達までの月数) ===
  SMA(3)  : 2 ヶ月
  SMA(12) : 10 ヶ月

出力の意味平滑度は原系列 13.77 → WMA 7.19 → SMA(3) 6.45 → SMA(12) 1.77 の順に滑らかになります。窓が大きいほど滑らか(SMA(12) は原系列の8分の1の凸凹)。ところが代償として遅れが増えます——1期先予測の平均誤差は SMA(12) が 24.68(トレンドの理論遅れ bk+12=2132=13b\frac{k+1}{2}=2\cdot\frac{13}{2}=13 に、第18月の +40 ステップへの追従の鈍さが上乗せされた値)。SMA(3) は 5.51、WMA(3) は 4.68 で、WMA は同じ窓3でも直近を重くするぶん遅れが小さい(重心が新しい)。ステップ応答は決定的で、+40 の段差の90%まで追いつくのに SMA(3) は2ヶ月、SMA(12) は10ヶ月もかかります。図でも赤い SMA(12) が第18月の段差を大きく出遅れて登っていくのが見えます。滑らかさと反応性は同時に手に入りません。

5. 窓 kk の選択はトレードオフ(コード)

では窓 kk はいくつが良いのか。水準+ゆるいトレンド+ノイズ(季節なし)の系列で、kk を変えて1期先予測の MAD(平均絶対偏差) を比べます。小さい kk はノイズに過敏(分散大)、大きい kk はトレンドに遅れる(バイアス大)——そのが最小になる中庸の kk があるはずです。

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import japanize_matplotlib

rng = np.random.default_rng(11)

# 水準 + ゆるいトレンド + ノイズ(季節なし)。窓kの選び方を「ノイズ除去 vs 反応性」で見る
n = 120
t = np.arange(n)
level, slope, sigma = 100.0, 1.5, 10.0
y = level + slope * t + rng.normal(0, sigma, n)
demand = pd.Series(y)

# 1期先SMA予測 F_{t+1}=直近k期平均。各kで同じ評価区間でMADを比較
ks = [1, 2, 3, 4, 6, 9, 12, 18, 24]
start = max(ks)                       # どのkでも予測が出せる位置から評価
rows = []
for k in ks:
    fc = demand.rolling(k).mean().shift(1)      # 時刻tの予測=t-1までのk期平均
    e = (demand - fc).iloc[start:]              # 予測誤差(共通区間)
    rows.append({"窓k": k, "MAD": e.abs().mean()})

tbl = pd.DataFrame(rows)
best_k = int(tbl.loc[tbl["MAD"].idxmin(), "窓k"])
print(tbl.to_string(index=False, float_format=lambda x: f"{x:.3f}"))
print(f"\nMAD最小の窓 k = {best_k}")
print("小さいk: ノイズに過敏(MAD大)/大きいk: トレンドに遅れる(MAD大)")

# 図:MAD vs 窓k のU字(トレードオフ)
plt.figure(figsize=(8, 5))
plt.plot(tbl["窓k"], tbl["MAD"], "o-", color="#1f77b4")
plt.plot(best_k, tbl["MAD"].min(), "*", color="#d62728", ms=18, label=f"最小 k={best_k}")
plt.xlabel("移動平均の窓 k"); plt.ylabel("1期先予測の MAD")
plt.title("窓kのトレードオフ:小さすぎ=ノイズに過敏/大きすぎ=トレンドに遅れる")
plt.legend(); plt.tight_layout(); plt.show()

出力:

 窓k    MAD
  1 11.289
  2 10.815
  3  9.882
  4  9.317
  6  9.679
  9 10.789
 12 12.092
 18 15.182
 24 19.106

MAD最小の窓 k = 4
小さいk: ノイズに過敏(MAD大)/大きいk: トレンドに遅れる(MAD大)

出力の意味:MAD は kk に対してU字を描きます——k=1k=1(=直前値をそのまま予測)は 11.29 とノイズを丸ごと拾い、k=24k=24 は 19.11 とトレンドへの遅れで悪化。**最小は k=4k=4(MAD 9.32)**で、ノイズ除去と反応性のバランスがそこにあります。「窓は大きいほど良い」でも「小さいほど反応的で良い」でもなく、データのノイズとトレンドの強さで最適 kk が決まるということです。トレンドがもっと急なら最適 kk は小さく、もっと平坦でノイズだらけなら大きくなります。最適 kk をデータから選ぶ手続き(時系列クロスバリデーション)は バックテストと予測の評価 に譲ります。

⚠️ よくある誤解

関連ノート