04.10.1
モデル × 前処理 × データ特性の精度比較
まとめこのページでは、月次 72 か月の合成データ 6 種類(線形トレンド・加法季節・乗法季節・構造変化・間欠需要・複合)に、4 つの前処理(なし・対数・Box-Cox・差分)と 5 つのモデル(季節ナイーブ・SES・HW加法・HW乗法・ARIMA(1,1,1))を総当たりで組み合わせ、120 通りのパイプラインを末尾 12 か月の MASE で採点します。MASE は季節ナイーブの MAE を分母にした指標なので、1.0 を下回ったセルだけが「単純な繰り返し予測に勝った」組み合わせです。結果は sns.heatmap で 6 行 × 20 列に並べ、さらに加法季節データで HW加法・SES・差分 + ARIMA の残差を plot_acf にかけて、MASE の数字だけでは見えない季節パターンの取りこぼしを確かめます。読み終えると、新しい系列を見たときにデータの性質から前処理とモデルの候補を絞り込めるようになります。
評価の仕組みは ウォークフォワード検証 と MASE で先に押さえておくと、ここで末尾 12 か月をテストに使う理由と「MASE = 1.0 が季節ナイーブと同じ精度」の意味が分かります。比較対象のモデルの基礎は 指数平滑法 にあります。
個々のモデルを単体で試すだけでは「このデータに最適な予測パイプライン」は見えてこない。ここでは、データの特性・前処理・モデルの 3 軸を同時に動かし、120 通りの組み合わせを一括で比較する。
直感
#
ARIMA を 1 本のデータに当ててみて MASE が 0.9 だったとして、それが「良い」のかは比較対象がないと判断できません。このページは料理の食材・下ごしらえ・調理法を総当たりで試すようなもので、6 種類のデータ(食材)に 4 つの前処理(下ごしらえ)と 5 つのモデル(調理法)を掛け合わせて 120 皿を作り、全部を同じ物差し MASE で採点します。ヒートマップを横に読めば「乗法季節データには対数変換 + HW乗法が効く」、縦に読めば「SES は季節性があるデータでは軒並み 1.0 を超える」といった規則が浮かび上がります。最後に残差の ACF を見て、ラグ 12 に残るスパイクから「なぜ SES は負けたのか」まで突き止めます。
6 種類の合成データを作る→
末尾 12 か月をテストに分ける→
前処理 4 種 × モデル 5 種を当てる→
逆変換して MASE を計算→
120 セルのヒートマップ→
残差 ACF で敗因を確認
1. 合成データの生成
#
実務で遭遇する代表的な 6 パターンの時系列を合成します。すべて月次・72 か月(6 年分)で、末尾 12 か月をテストに使います。次のコードは 6 系列を 2 行 3 列の折れ線に並べ、テスト期間の始まりを破線で示します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
| import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
rng = np.random.default_rng(42)
n = 72
dates = pd.date_range("2018-01-01", periods=n, freq="MS")
def make_datasets():
"""6 種類の合成時系列を生成する。"""
t = np.arange(n)
datasets = {}
# 1. 線形トレンド + ノイズ
datasets["線形トレンド"] = pd.Series(
50 + 0.8 * t + rng.normal(0, 3, n), index=dates
)
# 2. 加法季節性(トレンドなし)
season_add = 15 * np.sin(2 * np.pi * t / 12)
datasets["加法季節"] = pd.Series(
100 + season_add + rng.normal(0, 4, n), index=dates
)
# 3. 乗法季節性(トレンドあり)
level = 50 + 0.6 * t
season_mul = 1 + 0.3 * np.sin(2 * np.pi * t / 12)
datasets["乗法季節"] = pd.Series(
level * season_mul + rng.normal(0, 2, n), index=dates
)
# 4. 構造変化(レベルシフト)
shift = np.where(t >= 36, 30, 0)
datasets["構造変化"] = pd.Series(
80 + 0.3 * t + shift + rng.normal(0, 4, n), index=dates
)
# 5. 間欠需要(ゼロが多い)
demand = rng.choice([0, 0, 0, 1], size=n).astype(float)
demand[demand == 1] = rng.uniform(5, 30, size=int(demand.sum()))
datasets["間欠需要"] = pd.Series(demand, index=dates)
# 6. 複合パターン(トレンド + 季節 + AR(1) 残差)
ar_resid = np.zeros(n)
for i in range(1, n):
ar_resid[i] = 0.6 * ar_resid[i - 1] + rng.normal(0, 3)
datasets["複合"] = pd.Series(
60 + 0.5 * t + 10 * np.sin(2 * np.pi * t / 12) + ar_resid,
index=dates,
)
return datasets
datasets = make_datasets()
fig, axes = plt.subplots(2, 3, figsize=(14, 7), sharex=True)
colors = ["#2563eb", "#10b981", "#f97316", "#ef4444", "#8b5cf6", "#1e40af"]
for ax, (name, s), c in zip(axes.flat, datasets.items(), colors):
ax.plot(s.index, s.values, color=c, linewidth=1.2)
ax.axvline(s.index[-12], color="#94a3b8", linestyle="--", linewidth=0.8)
ax.set_title(name)
ax.grid(alpha=0.2)
fig.suptitle("6 種類の合成時系列(破線より右がテスト期間)", fontsize=13)
fig.tight_layout()
plt.show()
|

各データの特徴は以下のとおりです。
| データ | 特徴 | 難しい点 |
|---|
| 線形トレンド | 右肩上がり、季節性なし | トレンド成分だけ正しく捉えればよい |
| 加法季節 | 振幅一定の周期パターン | 季節性の位相とレベルの推定 |
| 乗法季節 | 値が大きくなるほど振幅拡大 | 加法モデルでは振幅を過小評価する |
| 構造変化 | 途中でレベルが急変 | 変化前のデータが予測を歪める |
| 間欠需要 | ゼロが 75% を占める | 連続値モデルの前提が崩れる |
| 複合 | トレンド + 季節 + 自己相関 | 全成分を同時に捉える必要がある |
何が起きているか(ステップで見る)
#
コードを読む前に、この手法が図の上で何をしているかを追ってみましょう。「再生」を押すか、スライダーを動かすと 1 手順ずつ進みます。
見るポイント:時系列の検証は原点を進めながら繰り返す。未来のデータを訓練に入れない。
2. パイプラインの定義
#
前処理・モデル・評価を統一的に回すために、3 つのヘルパー関数を定義します。preprocess は変換後の系列と逆変換の関数を対で返し、fit_and_forecast はモデル名で 5 手法を切り替え、compute_mase は季節ナイーブの MAE を分母にした MASE を返します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
| import warnings
from scipy.stats import boxcox
from scipy.special import inv_boxcox
from statsmodels.tsa.holtwinters import (
ExponentialSmoothing,
SimpleExpSmoothing,
)
from statsmodels.tsa.arima.model import ARIMA
warnings.filterwarnings("ignore")
# ---------- 前処理 ----------
def preprocess(series, method):
"""系列を変換し、逆変換関数を返す。"""
if method == "なし":
return series.copy(), lambda x: x
if method == "対数":
transformed = np.log1p(series)
return transformed, lambda x: np.expm1(x)
if method == "Box-Cox":
vals = series.values.astype(float)
if (vals <= 0).any():
vals = vals - vals.min() + 1 # 正値に補正
transformed, lam = boxcox(vals)
transformed = pd.Series(transformed, index=series.index)
return transformed, lambda x: inv_boxcox(x, lam)
if method == "差分":
first = series.iloc[0]
transformed = series.diff().dropna()
def inv_diff(x):
return np.cumsum(np.concatenate([[first], x]))[1:]
return transformed, inv_diff
raise ValueError(f"未知の前処理: {method}")
# ---------- モデル ----------
def fit_and_forecast(train, h, model_name, sp=12):
"""学習して h ステップ先を予測する。"""
if model_name == "季節ナイーブ":
last_season = train.values[-sp:]
reps = int(np.ceil(h / sp))
return np.tile(last_season, reps)[:h]
if model_name == "SES":
m = SimpleExpSmoothing(train, initialization_method="estimated").fit()
return m.forecast(h).values
if model_name == "HW加法":
m = ExponentialSmoothing(
train, trend="add", seasonal="add",
seasonal_periods=sp, initialization_method="estimated",
).fit()
return m.forecast(h).values
if model_name == "HW乗法":
m = ExponentialSmoothing(
train, trend="add", seasonal="mul",
seasonal_periods=sp, initialization_method="estimated",
).fit()
return m.forecast(h).values
if model_name == "ARIMA":
m = ARIMA(train, order=(1, 1, 1)).fit()
return m.forecast(steps=h).values
raise ValueError(f"未知のモデル: {model_name}")
# ---------- MASE ----------
def compute_mase(actual, predicted, train, m=12):
"""MASE を計算する。季節ナイーブの MAE を分母に使う。"""
e = np.abs(actual - predicted)
naive_errors = np.abs(train.values[m:] - train.values[:-m])
scale = naive_errors.mean()
if scale == 0:
return np.nan
return e.mean() / scale
PREPROCESS_METHODS = ["なし", "対数", "Box-Cox", "差分"]
MODEL_NAMES = ["季節ナイーブ", "SES", "HW加法", "HW乗法", "ARIMA"]
|
3. 全パイプラインの実行
#
120 通りの組み合わせを一括で実行し、MASE を収集します。前処理とモデルの組み合わせによっては学習が失敗する(たとえば HW乗法にゼロを含む系列を渡す)ため、try/except で NaN を記録して次に進みます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
| h = 12 # テスト期間
results = []
for data_name, series in datasets.items():
train_raw = series.iloc[:-h]
test_raw = series.iloc[-h:].values
for prep in PREPROCESS_METHODS:
try:
tr_series, inv_fn = preprocess(series, prep)
except Exception:
for model in MODEL_NAMES:
results.append((data_name, prep, model, np.nan))
continue
if prep == "差分":
train_tr = tr_series.iloc[: len(train_raw) - 1]
else:
train_tr = tr_series.iloc[: len(train_raw)]
for model in MODEL_NAMES:
try:
pred_tr = fit_and_forecast(train_tr, h, model, sp=12)
pred_raw = inv_fn(pred_tr)
if isinstance(pred_raw, pd.Series):
pred_raw = pred_raw.values
pred_raw = np.asarray(pred_raw, dtype=float)
mase = compute_mase(test_raw, pred_raw, train_raw, m=12)
except Exception:
mase = np.nan
results.append((data_name, prep, model, mase))
df_results = pd.DataFrame(results, columns=["データ", "前処理", "モデル", "MASE"])
# パイプライン名を作成
df_results["パイプライン"] = df_results["前処理"] + " + " + df_results["モデル"]
print("=== MASE 上位 5(小さいほど良い) ===")
print(df_results.nsmallest(5, "MASE")[["データ", "パイプライン", "MASE"]].to_string(index=False))
print()
print("=== MASE 下位 5(大きいほど悪い) ===")
print(df_results.nlargest(5, "MASE")[["データ", "パイプライン", "MASE"]].to_string(index=False))
|
4. MASE ヒートマップ
#
120 セルを一枚のヒートマップに描きます。行がデータ種別、列が「前処理 + モデル」の 20 通りの組み合わせで、MASE が 1 未満のセルは季節ナイーブより優秀、色が緑に近いほど精度が高いと読みます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| import seaborn as sns
pivot = df_results.pivot_table(
index="データ", columns="パイプライン", values="MASE",
)
# 列を前処理順 → モデル順にソート
order = [f"{p} + {m}" for p in PREPROCESS_METHODS for m in MODEL_NAMES]
pivot = pivot[[c for c in order if c in pivot.columns]]
# データ行の順序を固定
row_order = ["線形トレンド", "加法季節", "乗法季節", "構造変化", "間欠需要", "複合"]
pivot = pivot.reindex([r for r in row_order if r in pivot.index])
fig, ax = plt.subplots(figsize=(18, 5))
sns.heatmap(
pivot, annot=True, fmt=".2f", cmap="RdYlGn_r",
linewidths=0.5, ax=ax, vmin=0, vmax=3,
cbar_kws={"label": "MASE(低いほど良い)"},
)
ax.set_title("データ特性 × パイプライン MASE ヒートマップ", fontsize=13)
ax.set_xlabel("")
ax.set_ylabel("")
plt.xticks(rotation=45, ha="right", fontsize=8)
fig.tight_layout()
plt.show()
|

読み方のポイント
#
MASE が 1 を下回る緑系のセルは季節ナイーブより優秀で、値が小さいほど良い組み合わせです。逆に 1 を超える赤系のセルは「12 か月前の値をそのまま使う」予測に負けており、そのデータには前処理かモデルを変える必要があります。横方向に読むと、たとえば乗法季節の行で「対数 + HW乗法」や「Box-Cox + HW加法」が緑で「なし + SES」が赤、という形で同じデータに効くパイプラインが分かります。縦方向に読むと、SES の列は季節性を持つ 4 行で軒並み 1 を超えるなど、同じ手法がデータ特性でどう振れるかが分かります。「なし + 季節ナイーブ」の列は定義上ほぼ 1.0 なので、他のセルはこの列との相対評価として読んでください。
5. 誤差パターン分析
#
MASE の数字だけでは「なぜ負けたか」が分かりません。ここでは加法季節データに対する HW加法・SES・差分 + ARIMA の 3 パイプラインを取り上げ、左にテスト期間の予測と実測、右に残差の自己相関(ACF)を並べて、残差に季節パターンが残っているかを確かめます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
| from statsmodels.graphics.tsaplots import plot_acf
# 分析対象:加法季節の 3 パイプライン
target_data = "加法季節"
pipelines = [
("なし", "HW加法", "#2563eb"), # 想定ベスト
("なし", "SES", "#ef4444"), # 季節性を無視
("差分", "ARIMA", "#f97316"), # 差分 + ARIMA
]
series = datasets[target_data]
train_raw = series.iloc[:-h]
test_raw = series.iloc[-h:]
test_idx = test_raw.index
fig, axes = plt.subplots(len(pipelines), 2, figsize=(14, 3.5 * len(pipelines)))
for i, (prep, model, color) in enumerate(pipelines):
# 予測を再計算
tr_series, inv_fn = preprocess(series, prep)
if prep == "差分":
train_tr = tr_series.iloc[: len(train_raw) - 1]
else:
train_tr = tr_series.iloc[: len(train_raw)]
pred_tr = fit_and_forecast(train_tr, h, model, sp=12)
pred_raw = np.asarray(inv_fn(pred_tr), dtype=float)
residuals = test_raw.values - pred_raw
mase = compute_mase(test_raw.values, pred_raw, train_raw, m=12)
mbe = residuals.mean()
# 左: 実測 vs 予測
ax_left = axes[i, 0]
ax_left.plot(test_idx, test_raw.values, "o-", color="#94a3b8", label="実測", markersize=4)
ax_left.plot(test_idx, pred_raw, "s--", color=color, label="予測", markersize=4)
ax_left.set_title(f"{prep} + {model} (MASE={mase:.2f}, MBE={mbe:+.1f})")
ax_left.legend(fontsize=8)
ax_left.grid(alpha=0.2)
# 右: 残差 ACF
ax_right = axes[i, 1]
plot_acf(residuals, ax=ax_right, lags=10, color=color, title="")
ax_right.set_title("残差の自己相関")
fig.suptitle(f"「{target_data}」データにおける誤差パターン比較", fontsize=13, y=1.02)
fig.tight_layout()
plt.show()
|

残差から読み取れること
#
HW加法は季節成分をモデルに含むため、予測の破線が実測の山谷に重なり、残差の ACF は 10 本の棒がほぼ信頼区間の中に収まります。MBE が 0 に近ければ水準の偏りもありません。SES は季節成分を持たず予測が水平線になるので、山の月で残差が正、谷の月で負になり、ACF にはっきりした波の形(隣接ラグの正の相関と、半周期離れたラグの負の相関)が残ります。MASE が高いだけでなく、誤差が系統的に偏っているのが問題です。差分 + ARIMA は差分で定常化してから ARIMA(1,1,1) を当てますが、差分空間の予測誤差を np.cumsum で積み上げるので 12 か月先に向かって誤差が蓄積し、季節性も差分だけでは消えないため ACF に周期的な名残が見えます。
6. まとめと実務への応用
#
| データ特性 | 有効な前処理 | 有効なモデル | 理由 |
|---|
| 線形トレンドのみ | なし / 差分 | ARIMA, HW加法 | 差分 or トレンド成分で対処 |
| 加法季節 | なし | HW加法 | 加法季節をそのままモデル化 |
| 乗法季節 | 対数 / Box-Cox | HW乗法 | 変換で加法的に扱えるようになる |
| 構造変化 | なし | HW加法, ARIMA | 変化後のデータに重みが集まるモデルが有利 |
| 間欠需要 | なし | 季節ナイーブ | 連続値モデルは苦戦する |
| 複合パターン | なし | HW加法, ARIMA | 季節性 + 自己相関の両方を捉える |
MASE が 1 を下回ることが「季節ナイーブに勝った」ことの定義で、この基準を超えられないモデルは、そのデータに対して複雑さに見合う精度を出せていません。ヒートマップで緑のセルを探し、残差 ACF で白色雑音に近いことを確かめる、という 2 段階が実務でのモデル選びの最低ラインになります。
7. よくある失敗パターン
#
最初の失敗は、HW乗法に 0 以下の値を含むデータを渡すことです。seasonal="mul" は水準 × 季節係数で値を表すので 0 があると学習が例外で止まり、間欠需要のようなデータには log1p 変換か加法モデルを使います。二つ目は過剰な差分で、もともと定常に近い加法季節データを差分するとノイズが増幅されて MASE が悪化するため、ADF 検定で単位根を確かめてから差分を適用します。三つ目は残差の自己相関を見ないことで、MASE が 0.8 でも ACF のラグ 12 にスパイクが残っていれば季節成分を取りこぼしており、plot_acf で必ず確認します。四つ目は間欠需要に MAPE を使うことで、実測が 0 の月で 0 除算になるか値が極端に膨らむため、MASE やピンボールロスなどゼロに耐える指標に切り替えます。