Mímisbrunnr知恵の泉

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

🎓 レベル:標準 | 重要度:A(必須)

📎 前提:需要予測の枠組みと移動平均 | 指数平滑の数理:指数平滑法(SES・Holt・Holt-Winters)(時系列分析) | 次:予測誤差の評価と追跡信号

要点(BLUF)

1. 指数平滑:要点とαのトレードオフ(導出は譲る)

単純指数平滑(SES)の更新式は1本です。新しい観測 yty_t を重み α\alpha で、これまでの予測 FtF_t を重み 1α1-\alpha で混ぜます。

Ft+1=αyt+(1α)Ft,0<α<1F_{t+1} = \alpha\, y_t + (1-\alpha)\, F_t,\qquad 0<\alpha<1

これを展開すると過去への重みが α(1α)j\alpha(1-\alpha)^j指数的に減衰します(だから「指数」平滑)。α\alpha が大きいほど直近に強く反応し、小さいほど滑らか——移動平均の窓 kk と同じトレードオフを、連続的な1パラメータで表したものです。pandasewm で実演します(漸化式の手実装は 指数平滑法(SES・Holt・Holt-Winters) にあるので再掲しません)。

import numpy as np
import pandas as pd

rng = np.random.default_rng(1)

# 水準が第20期に 100 -> 120 に上がる系列(ノイズあり)
n = 40
level = np.where(np.arange(n) >= 20, 120.0, 100.0)
y = pd.Series(level + rng.normal(0, 4.0, n))

# 単純指数平滑 F_{t+1}=alpha*y_t+(1-alpha)*F_t を pandas.ewm で実演
# (漸化式の導出・SES/Holt/Holt-Winters・alpha推定は 03-01_指数平滑法 に譲る)
print("alpha  平滑度(差分SD)  水準シフト後118到達まで")
for a in [0.2, 0.6]:
    sm = y.ewm(alpha=a, adjust=False).mean()
    rough = sm.diff().dropna().std(ddof=1)              # 小さいほど滑らか
    after = sm.values[20:]                              # 第20期以降
    reach = int(np.argmax(after >= 118)) if (after >= 118).any() else -1
    print(f"{a:>4}   {rough:>10.2f}      {reach:>3d} 期後")

出力:

alpha  平滑度(差分SD)  水準シフト後118到達まで
 0.2         1.29       10 期後
 0.6         3.56        2 期後

出力の意味α=0.2\alpha=0.2 は滑らか(差分SD 1.29)だが水準シフトへの追従が10期と遅い。α=0.6\alpha=0.6 は粗い(3.56)が2期で追いつく。移動平均の窓と同じ「反応性 vs 平滑」を、α\alpha ひとつで連続調整できるわけです。SES は水準のみ・予測はフラットなので、トレンドには Holt(水準+トレンド)、季節には Holt-Winters(+季節)と部品を足します——その漸化式の導出と最適 α,β,γ\alpha,\beta,\gamma の推定は 指数平滑法(SES・Holt・Holt-Winters)、状態空間表現と予測区間は ETSモデルと状態空間表現 にあります。本稿は別アプローチ——季節指数法——に進みます。

2. 季節指数法:ratio-to-moving-average の6ステップ

季節指数法は、需要を乗法分解 yt=Tt×St×εty_t = T_t \times S_t \times \varepsilon_t と見て、季節成分 StS_t を月ごとの倍率(季節指数)として取り出す古典的手法です。Holt-Winters が季節をオンラインで逐次更新するのに対し、季節指数法は履歴から一括で季節パターンを推定し、集約計画やS&OPで「季節調整した素の需要」を見るのに使われます。

flowchart TB
  Y["原系列 y_t"] --> CMA["(1) 中心化移動平均 CMA_t<br/>(2x12 で水準を推定)"]
  Y --> RATIO["(2) 季節比 = y_t / CMA_t"]
  CMA --> RATIO
  RATIO --> IDX["(3)(4) 同月平均 → 正規化<br/>季節指数 S_j(合計 = m)"]
  IDX --> DES["(5) 季節調整 d_t = y_t / S_j"]
  DES --> TR["(5) トレンド/水準を当てる"]
  TR --> FC["(6) 将来を予測 → S_j を掛け戻す(再季節化)"]
  IDX --> FC

(1) 中心化移動平均 CMA:周期 mm の移動平均で季節を均し、トレンド+水準を取り出します。月次(m=12m=12 は偶数)は、ちょうど中心の月に揃えるため両端を半分にした2×12移動平均を使います。

CMAt=1m(12ytm/2+ytm/2+1++yt+m/21+12yt+m/2)\text{CMA}_t = \frac{1}{m}\left(\tfrac12 y_{t-m/2} + y_{t-m/2+1} + \cdots + y_{t+m/2-1} + \tfrac12 y_{t+m/2}\right)

(2)(3)(4) 季節比 → 同月平均 → 正規化:実値を CMA で割ると季節×不規則が残ります。同じ月の値を平均して不規則を均し、合計が mm になるよう正規化(乗法では12ヶ月の指数の合計が m=12m=12、平均1)。

rˉj=avgt:month(t)=jytCMAt,Sj=ml=1mrˉlrˉj(jSj=m)\bar r_j = \operatorname*{avg}_{t:\,\text{month}(t)=j}\frac{y_t}{\text{CMA}_t},\qquad S_j = \frac{m}{\sum_{l=1}^{m}\bar r_l}\,\bar r_j \quad\Big(\textstyle\sum_j S_j = m\Big)

(5)(6) 季節調整 → トレンド外挿 → 再季節化:原系列を季節指数で割って季節調整済み dtd_t を作り、そこにトレンド(や水準)を当てて将来を予測し、最後に季節指数を掛け戻します。

dt=ytSj(t),y^t+h=d^t+h×Sj(t+h)d_t = \frac{y_t}{S_{j(t)}},\qquad \hat y_{t+h} = \hat d_{t+h}\times S_{j(t+h)}

3. 季節指数を取り出す(コード)

3年(36ヶ月)の擬似需要——線形トレンド × 乗法季節 × ノイズ——から、ratio-to-moving-average で12個の季節指数を推定し、仕込んだ真値と比べます。

import numpy as np
import pandas as pd

rng = np.random.default_rng(7)

# 3年×12ヶ月 = 36ヶ月の擬似需要:線形トレンド × 乗法季節 × ノイズ
n_years, m = 3, 12
n = n_years * m
t = np.arange(n)
trend = 500.0 + 8.0 * t                       # 上昇トレンド
true_season = np.array([0.80, 0.75, 0.90, 1.00, 1.10, 1.25,
                        1.30, 1.20, 1.05, 0.95, 0.85, 0.85])  # 真の季節指数(合計12)
y = trend * true_season[t % m] * rng.normal(1.0, 0.05, n)     # 乗法ノイズ
demand = pd.Series(y, name="需要")

# ratio-to-moving-average
# (1) 中心化移動平均 CMA(2x12)で水準を推定。重みは [0.5,1,...,1,0.5]/12
half = m // 2
w = np.ones(m + 1); w[0] = 0.5; w[-1] = 0.5; w = w / m
cma = np.full(n, np.nan)
for i in range(half, n - half):
    cma[i] = np.dot(w, y[i - half:i + half + 1])

# (2) 季節比 = 実値 / CMA(季節×不規則)
ratio = y / cma

# (3) 同じ月の季節比を平均 → 生の季節指数
month = t % m
S_raw = np.array([np.nanmean(ratio[month == j]) for j in range(m)])

# (4) 合計が m(=12) になるよう正規化(乗法の制約 sum S_j = m)
S = S_raw * m / np.nansum(S_raw)

tbl = pd.DataFrame({
    "月": np.arange(1, m + 1),
    "推定季節指数": S,
    "真の季節指数": true_season,
})
print(tbl.to_string(index=False, float_format=lambda x: f"{x:.3f}"))
print(f"\n推定季節指数の合計 = {S.sum():.4f}  (正規化により m=12 に一致)")
print(f"真の季節指数との平均絶対差 = {np.mean(np.abs(S - true_season)):.4f}")

出力:

 月  推定季節指数  真の季節指数
 1   0.821   0.800
 2   0.744   0.750
 3   0.861   0.900
 4   1.027   1.000
 5   1.087   1.100
 6   1.271   1.250
 7   1.260   1.300
 8   1.224   1.200
 9   1.013   1.050
10   0.955   0.950
11   0.854   0.850
12   0.882   0.850

推定季節指数の合計 = 12.0000  (正規化により m=12 に一致)
真の季節指数との平均絶対差 = 0.0225

出力の意味:たった3年・乗法ノイズ込みのデータから、12個の季節指数を真値と平均 0.02 の精度で復元できました。6月・7月に需要が約25〜30%増し(指数 1.27, 1.26)、1月・2月は2割ほど落ち込む(0.82, 0.74)——という季節パターンが、勘ではなく数字で出ています。合計はちょうど 12.0000(正規化の効果)で、季節指数の平均が1=「季節をならすと水準に戻る」ことを保証します。CMA は両端6ヶ月ずつ計算できないので、各月の季節比は実質2年分の平均から推定しており、それでもこの精度です。

4. 季節調整 → トレンド → 再季節化で予測(コード)

季節指数で季節調整し、季節調整済み系列に線形トレンドnp.polyfit)を当て、翌12ヶ月を予測して季節指数を掛け戻します。

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

rng = np.random.default_rng(7)
n_years, m = 3, 12
n = n_years * m
t = np.arange(n)
trend = 500.0 + 8.0 * t
true_season = np.array([0.80, 0.75, 0.90, 1.00, 1.10, 1.25,
                        1.30, 1.20, 1.05, 0.95, 0.85, 0.85])
y = trend * true_season[t % m] * rng.normal(1.0, 0.05, n)

# 季節指数(02-02 コード1と同じ手順)を再計算
half = m // 2
w = np.ones(m + 1); w[0] = 0.5; w[-1] = 0.5; w = w / m
cma = np.full(n, np.nan)
for i in range(half, n - half):
    cma[i] = np.dot(w, y[i - half:i + half + 1])
month = t % m
S_raw = np.array([np.nanmean((y / cma)[month == j]) for j in range(m)])
S = S_raw * m / np.nansum(S_raw)

# (1) 季節調整:原系列を季節指数で割る
deseason = y / S[month]

# (2) 季節調整済みに線形トレンドを当てる(np.polyfit)
b1, b0 = np.polyfit(t, deseason, 1)            # 傾き b1・切片 b0
print(f"季節調整済みトレンド: 水準 = {b0:.1f} + {b1:.2f} * t")

# (3) 翌12ヶ月を予測 → (4) 季節指数を掛け戻す(再季節化)
t_future = np.arange(n, n + 12)
trend_future = b0 + b1 * t_future
fcast = trend_future * S[t_future % m]
print("\n翌12ヶ月の予測需要(再季節化後):")
for tf, f in zip(t_future, fcast):
    print(f"  月{tf + 1:2d} (季節指数 {S[tf % m]:.3f}): {f:7.1f}")

# 図:実績・季節調整済み・トレンド直線・予測
plt.figure(figsize=(11, 5))
plt.plot(t, y, "o-", color="0.5", lw=1, ms=4, label="実績需要")
plt.plot(t, deseason, color="#2ca02c", lw=1.5, label="季節調整済み")
plt.plot(t, b0 + b1 * t, "--", color="#2ca02c", lw=1, label="トレンド直線")
plt.plot(t_future, fcast, "s-", color="#d62728", lw=2, ms=5, label="予測(再季節化)")
plt.axvline(n - 0.5, ls=":", color="gray")
plt.xlabel("月(0 = 1年目1月)"); plt.ylabel("需要")
plt.title("季節指数法:季節調整 → トレンド外挿 → 再季節化")
plt.legend(); plt.tight_layout(); plt.show()

出力:

季節調整済みトレンド: 水準 = 497.8 + 7.25 * t

翌12ヶ月の予測需要(再季節化後):
  月37 (季節指数 0.821):   622.5
  月38 (季節指数 0.744):   570.0
  月39 (季節指数 0.861):   665.9
  月40 (季節指数 1.027):   801.6
  月41 (季節指数 1.087):   856.2
  月42 (季節指数 1.271):  1010.7
  月43 (季節指数 1.260):  1010.7
  月44 (季節指数 1.224):   991.0
  月45 (季節指数 1.013):   827.2
  月46 (季節指数 0.955):   787.2
  月47 (季節指数 0.854):   710.0
  月48 (季節指数 0.882):   739.4

出力の意味:季節調整済み系列はギザギザの季節が消えてほぼ直線になり(図の緑)、そこに当てたトレンドは 497.8+7.25t497.8 + 7.25\,t(真値 500+8t500+8t をほぼ復元)。これを将来へ伸ばして季節指数を掛け戻すと、予測(赤)はトレンドの上昇と季節の山谷を両方持った系列になります——夏の山(月42・43で約1010)と冬の谷(月38で570)。これは移動平均(需要予測の枠組みと移動平均)のフラットな予測には原理的に出せなかった形です。集約計画では、この「季節調整済みトレンド」で生産を平準化し、季節の山は在庫の積み増しで吸収する、という意思決定につながります。

⚠️ よくある誤解

関連ノート