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

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

言語 JA

【ベンチマーク】構造変化のあるデータ

中級

04.10.4

【ベンチマーク】構造変化のあるデータ

最終更新 2026-03-06 読了時間 6 分
まとめ
このページでは、25 か月目で水準が 30 ポイント跳ね上がり、トレンドも月 +0.5 から +1.0 に加速する 48 か月の合成データを使い、「どのモデルを選ぶか」より「どの期間で学習するか」が精度を決める場面を再現します。季節ナイーブ・SES・HW加法・ARIMA(1,1,1)・ARIMA(2,1,1) の 5 モデルを、変化前を含む全期間 36 か月と変化後だけの 12 か月の 2 通りで学習し、末尾 12 か月の MASE をグループ棒グラフで比べます。全期間で学習すると変化前の低い水準と緩いトレンドが推定を引っぱり、5 モデルすべてで MASE が 1 を大きく超えますが、変化後のみにすると多くのモデルで大幅に改善します。ARIMA(1,1,1) の残差の MBE(平均バイアス誤差)を見ると、全期間学習が系統的に過小予測していることが数値で確認できます。

コロナ禍による消費行動の変化、法改正による市場構造の転換、設備更新による歩留まりの向上——実務では「あるタイミングを境にデータの振る舞いが変わる」ケースが頻繁に起きる。このシナリオでは、構造変化を含む時系列に対して学習期間の選択がモデル精度にどれほど影響するかを検証する。

1. データの特徴 #

月次データ 48 か月(4 年分)で、前半 24 か月は水準 80 から月 +0.5 の緩やかな上昇、後半 24 か月は水準が 30 ポイント跳ね上がり、トレンドも月 +1.0 に加速します。テストは末尾 12 か月なので、学習に使えるのは変化前 24 か月と変化後 12 か月です。全期間で学習すると、SES や ARIMA は変化前 24 か月の低い水準に引っぱられて現在の水準を過小評価し、トレンド項を持つ HW加法も「月 +0.5 と +1.0 の中間」の傾きを推定してしまいます。

何が起きているか(ステップで見る) #

コードを読む前に、この手法が図の上で何をしているかを追ってみましょう。「再生」を押すか、スライダーを動かすと 1 手順ずつ進みます。

見るポイント:どこで区切ると左右のばらつきの合計が最小になるかを総当たりで探す。

2. 合成データの生成 #

次のコードは、np.where(t < 24, …) で前半と後半のトレンドを切り替えた 48 か月の系列を作り、変化点を紫の実線、テスト開始を赤の破線で示します。変化前の期間には薄い紫の帯を敷いて、どこまでが「古い振る舞い」かを一目で分かるようにします。

 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
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

rng = np.random.default_rng(42)
n = 48
dates = pd.date_range("2020-01-01", periods=n, freq="MS")
t = np.arange(n)

# 前半: 緩やかなトレンド, 後半: レベルシフト + 急トレンド
trend = np.where(t < 24, 0.5 * t, 0.5 * 24 + 30 + 1.0 * (t - 24))
noise = rng.normal(0, 4, n)
series = pd.Series(80 + trend + noise, index=dates)

h = 12
train_raw = series.iloc[:-h]
test_raw = series.iloc[-h:]
break_point = dates[24]

fig, ax = plt.subplots(figsize=(10, 5))
ax.plot(series.index, series.values, "o-", color="#94a3b8", markersize=4)
ax.axvline(break_point, color="#8b5cf6", linestyle="-", linewidth=1.5, label="構造変化点")
ax.axvline(series.index[-h], color="#ef4444", linestyle="--", linewidth=1, label="テスト開始")
ax.fill_betweenx([series.min() - 5, series.max() + 5],
                  series.index[0], break_point, alpha=0.08, color="#8b5cf6")
ax.set_title("構造変化を含む月次データ(紫=変化前, 赤破線=テスト開始)")
ax.set_ylabel("値")
ax.legend()
ax.grid(alpha=0.2)
fig.tight_layout()
plt.show()

構造変化を含むデータ

3. 比較するパイプライン #

2 つの学習戦略を同じ 5 モデルで比べます。

戦略学習期間モデル
全期間月1〜36季節ナイーブ, SES, HW加法, ARIMA(1,1,1), ARIMA(2,1,1)
変化後のみ月25〜36季節ナイーブ, SES, HW加法, ARIMA(1,1,1), ARIMA(2,1,1)

4. 実験と結果 #

次のコードは 5 モデルを forecast_model に集め、train_all(36 か月)と train_post(12 か月)の 2 通りで学習して 12 か月先を予測します。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
79
import warnings
from statsmodels.tsa.holtwinters import ExponentialSmoothing, SimpleExpSmoothing
from statsmodels.tsa.arima.model import ARIMA

warnings.filterwarnings("ignore")

def compute_mase(actual, predicted, train, m=1):
    e = np.abs(actual - predicted)
    scale = np.abs(train.values[m:] - train.values[:-m]).mean()
    return e.mean() / scale if scale > 0 else np.nan

def forecast_model(train, h, name):
    if name == "季節ナイーブ":
        return np.full(h, train.values[-1])
    if name == "SES":
        return SimpleExpSmoothing(train, initialization_method="estimated").fit().forecast(h).values
    if name == "HW加法":
        return ExponentialSmoothing(
            train, trend="add", seasonal=None,
            initialization_method="estimated",
        ).fit().forecast(h).values
    if name == "ARIMA(1,1,1)":
        return ARIMA(train, order=(1, 1, 1)).fit().forecast(steps=h).values
    if name == "ARIMA(2,1,1)":
        return ARIMA(train, order=(2, 1, 1)).fit().forecast(steps=h).values
    raise ValueError(name)

models = ["季節ナイーブ", "SES", "HW加法", "ARIMA(1,1,1)", "ARIMA(2,1,1)"]

# 全期間で学習
train_all = series.iloc[:-h]
# 変化後のみで学習 (月25以降)
train_post = series.iloc[24:-h]

results_all = {}
results_post = {}
for m in models:
    try:
        results_all[m] = forecast_model(train_all, h, m)
    except Exception:
        results_all[m] = np.full(h, np.nan)
    try:
        results_post[m] = forecast_model(train_post, h, m)
    except Exception:
        results_post[m] = np.full(h, np.nan)

mase_all = {m: compute_mase(test_raw.values, results_all[m], train_all) for m in models}
mase_post = {m: compute_mase(test_raw.values, results_post[m], train_post) for m in models}

# グループ棒グラフ
x = np.arange(len(models))
width = 0.35

fig, ax = plt.subplots(figsize=(10, 5))
bars1 = ax.bar(x - width / 2, [mase_all[m] for m in models], width,
               label="全期間で学習", color="#ef4444", alpha=0.8)
bars2 = ax.bar(x + width / 2, [mase_post[m] for m in models], width,
               label="変化後のみで学習", color="#2563eb", alpha=0.8)
ax.axhline(1.0, color="#94a3b8", linestyle="--", linewidth=0.8)
ax.set_xticks(x)
ax.set_xticklabels(models, fontsize=9)
ax.set_ylabel("MASE")
ax.set_title("学習期間の選択がMASEに与える影響")
ax.legend()

for bar in bars1:
    h_val = bar.get_height()
    if not np.isnan(h_val):
        ax.text(bar.get_x() + bar.get_width() / 2, h_val + 0.05,
                f"{h_val:.2f}", ha="center", va="bottom", fontsize=8)
for bar in bars2:
    h_val = bar.get_height()
    if not np.isnan(h_val):
        ax.text(bar.get_x() + bar.get_width() / 2, h_val + 0.05,
                f"{h_val:.2f}", ha="center", va="bottom", fontsize=8)

ax.grid(alpha=0.2, axis="y")
fig.tight_layout()
plt.show()

学習期間による精度差

読み方のポイント #

全期間で学習した赤い棒は、SES も HW加法も ARIMA も MASE が 1 を大幅に超えます。変化前 24 か月の低い水準と月 +0.5 の緩いトレンドが推定を引っぱり、テスト期間の実際の水準(約 125〜140)に届かないためです。変化後のみで学習した青い棒は、12 点しか使っていないのに多くのモデルで MASE が半分以下に縮み、HW加法や ARIMA(1,1,1) は 1 の近くまで下がります。同じ ARIMA(1,1,1) で赤と青の差が 2 倍以上あるのに対し、青の棒どうし(モデル間)の差は 0.3 程度に収まるので、このデータでは「どのモデルか」より「どの期間か」が精度を決めています。

5. 誤差パターン分析 #

MASE だけでは「外れが一方向に偏っているか」が分かりません。次のコードは ARIMA(1,1,1) に絞り、上段に全期間学習と変化後学習の予測を実測と重ね、下段にテスト 12 か月の残差を棒で描いて平均(MBE)を破線で示します。

 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
fig, axes = plt.subplots(2, 2, figsize=(13, 8))
test_idx = test_raw.index

# 全期間 ARIMA vs 変化後 ARIMA
for i, (label, pred, color) in enumerate([
    ("全期間 ARIMA(1,1,1)", results_all["ARIMA(1,1,1)"], "#ef4444"),
    ("変化後 ARIMA(1,1,1)", results_post["ARIMA(1,1,1)"], "#2563eb"),
]):
    ax = axes[0, i]
    ax.plot(test_idx, test_raw.values, "o-", color="#94a3b8", markersize=4, label="実測")
    ax.plot(test_idx, pred, "s--", color=color, markersize=4, label="予測")
    m_val = compute_mase(test_raw.values, pred, train_all if "全期間" in label else train_post)
    ax.set_title(f"{label}  (MASE={m_val:.2f})")
    ax.legend(fontsize=8)
    ax.grid(alpha=0.2)

# 残差の分布
for i, (label, pred, train_used, color) in enumerate([
    ("全期間", results_all["ARIMA(1,1,1)"], train_all, "#ef4444"),
    ("変化後", results_post["ARIMA(1,1,1)"], train_post, "#2563eb"),
]):
    ax = axes[1, i]
    resid = test_raw.values - pred
    ax.bar(range(len(resid)), resid, color=color, alpha=0.7)
    ax.axhline(0, color="black", linewidth=0.5)
    ax.axhline(resid.mean(), color=color, linestyle="--", label=f"MBE={resid.mean():.1f}")
    ax.set_title(f"{label}学習の残差")
    ax.set_xlabel("テスト月")
    ax.legend(fontsize=8)
    ax.grid(alpha=0.2)

fig.suptitle("ARIMA(1,1,1) の学習期間別 予測 vs 残差", fontsize=13)
fig.tight_layout()
plt.show()

誤差パターン分析

全期間で学習した ARIMA(1,1,1) は、上段左で予測の破線が実測の点列の下をずっと走り、下段左では 12 本の残差の棒がすべて正の側に立って MBE が大きな正の値になります。これは変化前 24 か月の低い水準に引きずられた系統的な過小予測で、ばらつきの問題ではありません。変化後 12 か月だけで学習した ARIMA(1,1,1) は、上段右で破線が実測の点列の中を通り、下段右では棒が 0 の上下に散らばって MBE がほぼ 0 になります。残差が白色雑音に近づいたことで、ARIMA の予測区間も信頼できるものになります。

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

最初の失敗は「データは多いほどよい」と思い込むことです。構造変化がある場合、変化前の 24 か月を含めるほど水準とトレンドの推定が古い振る舞いに寄り、このページの例では 5 モデルすべてで MASE が悪化しました。12 点しかなくても変化後だけで学習した方が精度は高くなります。二つ目は変化点を目視で決めることで、グラフを眺めて「この辺り」と選ぶと分析者ごとに結果が変わります。ruptures パッケージの Pelt や Binseg、あるいはこのページのステップ図で見た「区切りの左右のばらつきの合計が最小になる点」を総当たりで探す方法で、変化点を数値として再現可能に決めます。三つ目は、変化点を見つけても古いデータを捨てる以外の選択肢を考えないことで、変化点のダミー変数を外生変数として SARIMAX に渡すか、Prophet の changepoints に日付を指定すれば、変化前のデータも傾きの推定に活かせます。