K_DM Book
用語集
対象者 標準
対象者別の入口 →

対象者の設定はこのブラウザの中にだけ保存されます。

言語 JA

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

中級

04.10.1

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

最終更新 2026-03-06 読了時間 9 分
まとめ
このページでは、月次 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 は負けたのか」まで突き止めます。

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

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

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

データ特徴難しい点
線形トレンド右肩上がり、季節性なしトレンド成分だけ正しく捉えればよい
加法季節振幅一定の周期パターン季節性の位相とレベルの推定
乗法季節値が大きくなるほど振幅拡大加法モデルでは振幅を過小評価する
構造変化途中でレベルが急変変化前のデータが予測を歪める
間欠需要ゼロが 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 ヒートマップ

読み方のポイント #

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-CoxHW乗法変換で加法的に扱えるようになる
構造変化なし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 やピンボールロスなどゼロに耐える指標に切り替えます。