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

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

言語 JA

【ベンチマーク】複数の周期が重なるデータ

中級

04.10.3

【ベンチマーク】複数の周期が重なるデータ

最終更新 2026-03-06 読了時間 6 分
まとめ
電力消費量や Web トラフィックのように、曜日の波(周期 7)と年間の波(周期 365)が同時に乗っている日次データを扱います。Holt-Winters や SARIMAX の基本形は seasonal_periods を 1 つしか持てないので、週次に合わせると年次を取りこぼし、年次に合わせると 365 個の季節係数で推定が不安定になります。このページでは振幅 10 の週次と振幅 25 の年次を重ねた 730 日の合成データに 6 つのモデルを当て、末尾 28 日の MASE で比べます。年次の波を sin(2πkt/365.25) と cos(2πkt/365.25) の 3 次までのフーリエ項として SARIMAX の exog に渡す方法が、週次と年次を同時に捉えて唯一 MASE を大きく下げます。残差の ACF を見ると、週次だけのモデルには年次成分の名残が長周期の相関として残ることが確認できます。

電力消費量・気温・Webトラフィックなど、「曜日の波」と「年間の波」が同時に存在するデータは多い。ここでは2つの周期が重なる合成データを生成し、季節パラメーターが1つしか設定できないモデルの限界と、フーリエ特徴量による回避策を検証する。

1. データの特徴 #

このシナリオの難しさは、seasonal_periods を 1 つしか指定できないモデル(Holt-Winters や SARIMAX の基本形)が大半であることです。週次パターン(sp=7)を指定すれば年次パターンを見逃し、年次パターン(sp=365)を指定すれば季節係数が 365 個になってパラメータ数が爆発し、2 年分 730 点では 1 係数あたり 2 点しか使えずフィッティングが不安定になります。回避策は、長い周期の方を少数の sin/cos 項で近似して外生変数に渡すことで、3 次のフーリエ項なら 6 列で年次の滑らかな波を表せます。

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

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

見るポイント:水準 → 傾き → 季節 と要素を足すほど、未来への伸ばし方が豊かになる。

2. 合成データの生成 #

次のコードは、水準 200 に周期 7 日・振幅 10 の波、周期 365.25 日・振幅 25 の波、1 日 0.02 のトレンド、標準偏差 5 のノイズを足した 730 日の系列を作ります。上段に全期間、下段に直近 60 日を拡大して描き、週次の波が見えるようにします。

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

rng = np.random.default_rng(42)
n = 730  # 2年分
dates = pd.date_range("2022-01-01", periods=n, freq="D")
t = np.arange(n)

# 週次周期 + 年次周期 + 緩やかなトレンド
weekly = 10 * np.sin(2 * np.pi * t / 7)
yearly = 25 * np.sin(2 * np.pi * t / 365.25)
trend = 0.02 * t
noise = rng.normal(0, 5, n)
series = pd.Series(200 + trend + weekly + yearly + noise, index=dates)

h = 28
train_raw = series.iloc[:-h]
test_raw = series.iloc[-h:]

fig, axes = plt.subplots(2, 1, figsize=(12, 6), gridspec_kw={"height_ratios": [2, 1]})
axes[0].plot(series.index, series.values, color="#94a3b8", linewidth=0.5)
axes[0].axvline(series.index[-h], color="#ef4444", linestyle="--")
axes[0].set_title("週次 + 年次の複合季節データ(赤線より右がテスト)")
axes[0].grid(alpha=0.2)

# 直近2か月を拡大
recent = series.iloc[-60:]
axes[1].plot(recent.index, recent.values, color="#2563eb", linewidth=1)
axes[1].set_title("直近2か月の拡大(週次の波が見える)")
axes[1].grid(alpha=0.2)

fig.tight_layout()
plt.show()

複数周期が重なるデータ

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

モデル季節性の扱い備考
季節ナイーブ (sp=7)週次のみ年次は無視
SESなし両方無視
Holt-Winters 加法 (sp=7)週次のみ年次は残差に漏出
ARIMA(2,1,1)なし両方無視
SARIMAX(1,0,1)(1,1,1,7)週次のみ年次は捉えられない
SARIMAX + フーリエ(年次)週次 + 年次sin/cos項を外生変数に追加

4. 実験と結果 #

次のコードは年次周期を 3 次のフーリエ項(6 列)に変換する make_fourier を定義し、6 つのモデルで 28 日先を予測して MASE を横棒グラフにします。季節ナイーブ基準の m=7 で 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
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
80
import warnings
from statsmodels.tsa.holtwinters import ExponentialSmoothing, SimpleExpSmoothing
from statsmodels.tsa.statespace.sarimax import SARIMAX
from statsmodels.tsa.arima.model import ARIMA

warnings.filterwarnings("ignore")

def compute_mase(actual, predicted, train, m=7):
    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 make_fourier(index, period, order):
    """sin/cos のフーリエ特徴量を生成。"""
    t = np.arange(len(index))
    cols = {}
    for k in range(1, order + 1):
        cols[f"sin_{period}_{k}"] = np.sin(2 * np.pi * k * t / period)
        cols[f"cos_{period}_{k}"] = np.cos(2 * np.pi * k * t / period)
    return pd.DataFrame(cols, index=index)

# フーリエ特徴量(年次周期をsin/cosで表現)
fourier_all = make_fourier(series.index, period=365.25, order=3)
fourier_train = fourier_all.iloc[:-h]
fourier_test = fourier_all.iloc[-h:]

results = {}

# 1. 季節ナイーブ
last7 = train_raw.values[-7:]
results["季節ナイーブ(7)"] = np.tile(last7, 4)[:h]

# 2. SES
results["SES"] = SimpleExpSmoothing(
    train_raw, initialization_method="estimated"
).fit().forecast(h).values

# 3. HW加法 (sp=7)
results["HW加法(7)"] = ExponentialSmoothing(
    train_raw, trend="add", seasonal="add", seasonal_periods=7,
    initialization_method="estimated",
).fit().forecast(h).values

# 4. ARIMA(2,1,1)
results["ARIMA"] = ARIMA(train_raw, order=(2, 1, 1)).fit().forecast(steps=h).values

# 5. SARIMAX(1,0,1)(1,1,1,7)
results["SARIMAX(7)"] = SARIMAX(
    train_raw, order=(1, 0, 1), seasonal_order=(1, 1, 1, 7),
    enforce_stationarity=False, enforce_invertibility=False,
).fit(disp=False).forecast(steps=h).values

# 6. SARIMAX + フーリエ(年次)
model_f = SARIMAX(
    train_raw, exog=fourier_train, order=(1, 0, 1), seasonal_order=(1, 1, 1, 7),
    enforce_stationarity=False, enforce_invertibility=False,
).fit(disp=False)
results["SARIMAX+フーリエ"] = model_f.forecast(steps=h, exog=fourier_test).values

# MASE 計算
mase_scores = {name: compute_mase(test_raw.values, pred, train_raw, m=7)
               for name, pred in results.items()}

# 棒グラフ
names = list(mase_scores.keys())
scores = [mase_scores[n] for n in names]
colors = ["#10b981" if s < 1 else "#ef4444" for s in scores]

fig, ax = plt.subplots(figsize=(10, 5))
bars = ax.barh(names, scores, color=colors, edgecolor="white")
ax.axvline(1.0, color="#1e40af", linestyle="--", linewidth=1, label="MASE = 1(季節ナイーブ水準)")
ax.set_xlabel("MASE(低いほど良い)")
ax.set_title("複数季節データ: モデル別 MASE 比較")
ax.legend()
for bar, s in zip(bars, scores):
    ax.text(bar.get_width() + 0.05, bar.get_y() + bar.get_height() / 2,
            f"{s:.2f}", va="center", fontsize=9)
ax.grid(alpha=0.2, axis="x")
fig.tight_layout()
plt.show()

モデル別MASE比較

読み方のポイント #

SARIMAX + フーリエだけが週次と年次の両方を捉え、MASE が最も低い緑の棒になります。SARIMAX(7) と HW加法(7) は週次は捉えるものの、振幅 25 の年次成分が残差に漏れるため MASE が 1 を切るかどうかの境目にとどまり、テスト 28 日間の年次の局面によっては季節ナイーブに負けることもあります。SES と ARIMA(2,1,1) は季節性を持たないので曜日の波に毎日 ±10 ずれ、MASE が 1 を大きく超えて赤くなります。棒グラフの順序は実行環境の statsmodels のバージョンで多少入れ替わりますが、「フーリエ付きが最良、季節性なしが最悪」という構図は変わりません。

5. 誤差パターン分析 #

MASE では「どの周期を取りこぼしたか」が分かりません。次のコードは SARIMAX + フーリエ、SARIMAX(7)、SES の 3 つについて、左にテスト 28 日の実測と予測、右に残差の ACF(ラグ 14 まで)を並べ、残差に週次や年次の名残が残っているかを確かめます。

 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
from statsmodels.graphics.tsaplots import plot_acf

targets = [
    ("SARIMAX+フーリエ", "#2563eb"),
    ("SARIMAX(7)", "#f97316"),
    ("SES", "#ef4444"),
]

fig, axes = plt.subplots(len(targets), 2, figsize=(13, 3.5 * len(targets)))
test_idx = test_raw.index

for i, (name, color) in enumerate(targets):
    pred = results[name]
    residuals = test_raw.values - pred
    mase = mase_scores[name]

    ax_l = axes[i, 0]
    ax_l.plot(test_idx, test_raw.values, "o-", color="#94a3b8", markersize=3, label="実測")
    ax_l.plot(test_idx, pred, "s--", color=color, markersize=3, label="予測")
    ax_l.set_title(f"{name}  (MASE={mase:.2f})")
    ax_l.legend(fontsize=8)
    ax_l.grid(alpha=0.2)

    ax_r = axes[i, 1]
    plot_acf(residuals, ax=ax_r, lags=14, color=color, title="")
    ax_r.set_title("残差ACF")

fig.suptitle("複数季節データにおける誤差パターン比較", fontsize=13, y=1.02)
fig.tight_layout()
plt.show()

誤差パターン分析

SARIMAX + フーリエの残差はほぼ白色雑音になり、ACF の 14 本の棒は信頼区間の帯の中に収まります。SARIMAX(7) の残差には年次成分の名残が「ラグ 1〜5 で正の相関が緩やかに減る」形の長周期の自己相関として現れ、左の図では予測の破線が実測の点列から一定方向にずれています。SES は曜日の波を全く持たないので、残差に周期 7 の波がそのまま残り、ACF のラグ 7 と 14 に顕著なスパイクが立ちます。

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

最初の失敗は seasonal_periods=365 を直接指定することで、Holt-Winters なら 365 個の季節係数を 2 年分 730 点から推定することになり、1 係数あたり 2 点では収束しないか過学習します。年次のような長い周期は 3〜5 次のフーリエ項(6〜10 列)で近似し、exog に渡すのが現実的です。二つ目は季節成分を 1 つ指定して満足することで、seasonal_periods=7 で MASE が 0.9 を切っても、残差 ACF に長周期の相関が残っていれば年次を取りこぼしています。plot_acf で残差を確認し、帯を超える棒の周期から足りない成分を特定します。三つ目はフーリエ項の次数を上げすぎることで、10 次(20 列)にすると年次の波に細かいギザギザまで当てはめ、2 年分のデータでは学習期間だけに合う形になります。