モデル × 前処理 × データ特性の精度比較

中級

5.10.1

モデル × 前処理 × データ特性の精度比較

最終更新 2026-03-06 読了時間 7 分
まとめ
  • 6 種類の合成データ × 4 つの前処理 × 5 つの予測モデル = 120 通りのパイプラインを体系的に比較する。
  • MASE ヒートマップで「どのデータ特性にどの組み合わせが強いか」を俯瞰する。
  • 集計指標では見えない残差の偏りや自己相関を分析し、モデル改善の方向を見極める。

個々のモデルを単体で試すだけでは「このデータに最適な予測パイプライン」は見えてこない。ここでは、データの特性・前処理・モデルの 3 軸を同時に動かし、120 通りの組み合わせを一括で比較する。


1. 合成データの生成 #

まず、実務で遭遇する代表的な 6 パターンの時系列を合成する。すべて月次・72 か月(6 年分)で、末尾 12 か月をテストに使う。

 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()

6 種類の合成時系列データ

各データの特徴は以下のとおり。

データ特徴難しい点
線形トレンド右肩上がり、季節性なしトレンド成分だけ正しく捉えればよい
加法季節振幅一定の周期パターン季節性の位相とレベルの推定
乗法季節値が大きくなるほど振幅拡大加法モデルでは振幅を過小評価する
構造変化途中でレベルが急変変化前のデータが予測を歪める
間欠需要ゼロが 75% を占める連続値モデルの前提が崩れる
複合トレンド + 季節 + 自己相関全成分を同時に捉える必要がある

2. パイプラインの定義 #

前処理・モデル・評価を統一的に回すためのヘルパー関数を定義する。

 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 を収集する。前処理とモデルの組み合わせによっては失敗するケースがあるため、try/exceptNaN を記録する。

 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 セルを一枚のヒートマップに描く。行がデータ種別、列が「前処理 + モデル」の組み合わせ。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 ヒートマップ

読み方のポイント #

  • MASE < 1(緑系)のセルは季節ナイーブより優秀。値が小さいほど良い。
  • MASE > 1(赤系)のセルは季節ナイーブに負けている。前処理やモデルの選択を再考すべき。
  • 横方向に比較すると「同じデータに対してどのパイプラインが効くか」がわかる。
  • 縦方向に比較すると「同じパイプラインがデータ特性によってどう変わるか」がわかる。
  • 「なし + 季節ナイーブ」列は定義上 MASE = 1.0 になる。他のセルはこの列との相対評価として読める。

5. 誤差パターン分析 #

集計指標(MASE)だけでは見えない残差の構造を分析する。ここでは代表的な 3 ケースを取り上げ、予測 vs 実測と残差の自己相関を確認する。

 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加法:季節性をモデルに含むため残差にパターンが残りにくい。MBE が 0 に近ければバイアスもない。
  • SES:季節成分を無視するため、残差に明確な周期パターン(ACF のラグ 12 付近にスパイク)が残る。MASE が高いだけでなく、予測に系統的な偏りがある。
  • 差分 + ARIMA:差分で定常化してから ARIMA を適用。逆変換の誤差伝播で精度が落ちることがあり、残差の ACF でその影響を確認できる。

6. まとめと実務への応用 #

データ特性有効な前処理有効なモデル理由
線形トレンドのみなし / 差分ARIMA, HW加法差分 or トレンド成分で対処
加法季節なしHW加法加法季節をそのままモデル化
乗法季節対数 / Box-CoxHW乗法変換で加法的に扱えるようになる
構造変化なしHW加法, ARIMA変化後のデータに重みが集まるモデルが有利
間欠需要なし季節ナイーブ連続値モデルは苦戦する
複合パターンなしHW加法, ARIMA季節性 + 自己相関の両方を捉える

MASE < 1 が「季節ナイーブに勝った」ことを意味する。この基準を超えられないモデルは、そのデータに対しては複雑さに見合う精度を出せていない。


7. よくある失敗パターン #

  • 乗法モデルに非正値データを渡す: HW乗法は値が 0 以下だとエラーになる。間欠需要のようにゼロを含むデータには対数変換か加法モデルを使う。
  • 過剰な差分: もともと定常に近いデータを差分するとノイズが増幅される。ADF 検定で定常性を確認してから差分を適用する。
  • 残差の自己相関を無視する: MASE が低くても残差に周期的なパターンが残っていれば、モデルが系列の構造を取りこぼしている。ACF プロットで必ず確認する。
  • 間欠需要に MAPE を使う: ゼロ除算でエラーになるか、値が極端に膨らむ。MASE やピンボールロスなど代替指標を使う。