【ベンチマーク】曜日パターンのある日次データ

中級

5.10.2

【ベンチマーク】曜日パターンのある日次データ

最終更新 2026-03-06 読了時間 5 分
まとめ
  • 平日と週末で水準が異なる日次時系列に対し、5つのモデル × 4つの前処理の精度を比較する。
  • 週次の季節性(sp=7)を明示するモデルが圧倒的に有利であることをMASEヒートマップで確認する。
  • 曜日を無視するモデルは残差に周期7のパターンが残り、集計指標以上に誤差構造が問題になる。

売上・アクセス数・問い合わせ件数など、「平日と週末で挙動が違う」データは実務で頻繁に遭遇する。ここでは曜日パターンを持つ合成データを生成し、前処理とモデルの組み合わせがどの程度の精度差を生むかを検証する。


1. データの特徴 #

このシナリオでは、月曜〜金曜は高水準、土日は低水準になるデータを扱う。トレンドは緩やかに右肩上がりで、ノイズが加わる。週次の季節性(周期7)をモデルが明示的に捉えられるかどうかが精度の分かれ目になる。

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

# 曜日効果: 月〜金 = +20, 土日 = -10
dow = dates.dayofweek  # 0=Mon ... 6=Sun
weekday_effect = np.where(dow < 5, 20, -10).astype(float)

trend = 0.05 * t
noise = rng.normal(0, 5, n)
series = pd.Series(100 + trend + weekday_effect + noise, index=dates)

h = 28  # テスト期間(4週間)
train_raw = series.iloc[:-h]
test_raw = series.iloc[-h:]

fig, axes = plt.subplots(2, 1, figsize=(12, 6), gridspec_kw={"height_ratios": [3, 1]})
axes[0].plot(series.index, series.values, color="#94a3b8", linewidth=0.5)
axes[0].axvline(series.index[-h], color="#ef4444", linestyle="--", linewidth=0.8)
axes[0].set_title("曜日パターンのある日次データ(赤線より右がテスト期間)")
axes[0].set_ylabel("値")
axes[0].grid(alpha=0.2)

# 直近8週分を拡大
recent = series.iloc[-8 * 7 :]
colors = ["#2563eb" if d < 5 else "#f97316" for d in recent.index.dayofweek]
axes[1].bar(recent.index, recent.values, color=colors, width=0.8)
axes[1].set_title("直近8週の拡大(青=平日, 橙=週末)")
axes[1].set_ylabel("値")
axes[1].grid(alpha=0.2)

fig.tight_layout()
plt.show()

曜日パターンのある日次データ

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

前処理モデル季節性の扱い
なし季節ナイーブ (sp=7)明示
なしSESなし
なしHolt-Winters 加法 (sp=7)明示
なしARIMA(1,1,1)なし
なしSARIMAX(1,1,1)(1,1,1,7)明示
対数変換季節ナイーブ / SES / HW / ARIMA / SARIMAX同上
週次リサンプリング季節ナイーブ / SES / HW / ARIMA週次化で曜日情報消失
差分季節ナイーブ / SES / HW / ARIMA / SARIMAX同上

4. 実験と結果 #

  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
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
import warnings
from statsmodels.tsa.holtwinters import ExponentialSmoothing, SimpleExpSmoothing
from statsmodels.tsa.statespace.sarimax import SARIMAX
from statsmodels.tsa.arima.model import ARIMA
from scipy.stats import boxcox
from scipy.special import inv_boxcox

warnings.filterwarnings("ignore")

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

def preprocess_ts(series, method):
    if method == "なし":
        return series.copy(), lambda x: x
    if method == "対数":
        return np.log1p(series), lambda x: np.expm1(x)
    if method == "差分":
        first = series.iloc[0]
        return series.diff().dropna(), lambda x: np.cumsum(np.concatenate([[first], x]))[1:]
    if method == "週次":
        weekly = series.resample("W").mean()
        return weekly, lambda x: x  # スケール変換なし
    raise ValueError(method)

def forecast_ts(train, h, name, sp=7):
    if name == "季節ナイーブ":
        last = train.values[-sp:]
        return np.tile(last, int(np.ceil(h / sp)))[:h]
    if name == "SES":
        return SimpleExpSmoothing(train, initialization_method="estimated").fit().forecast(h).values
    if name == "HW加法":
        return ExponentialSmoothing(
            train, trend="add", seasonal="add", seasonal_periods=sp,
            initialization_method="estimated",
        ).fit().forecast(h).values
    if name == "ARIMA":
        return ARIMA(train, order=(1, 1, 1)).fit().forecast(steps=h).values
    if name == "SARIMAX":
        return SARIMAX(
            train, order=(1, 1, 1), seasonal_order=(1, 1, 1, sp),
            enforce_stationarity=False, enforce_invertibility=False,
        ).fit(disp=False).forecast(steps=h).values
    raise ValueError(name)

preps = ["なし", "対数", "差分"]
models = ["季節ナイーブ", "SES", "HW加法", "ARIMA", "SARIMAX"]

results = []
for prep in preps:
    try:
        tr_s, inv_fn = preprocess_ts(series, prep)
    except Exception:
        for m in models:
            results.append((prep, m, np.nan))
        continue
    if prep == "差分":
        train_tr = tr_s.iloc[:len(train_raw) - 1]
    else:
        train_tr = tr_s.iloc[:len(train_raw)]
    for model_name in models:
        try:
            pred = forecast_ts(train_tr, h, model_name, sp=7)
            pred_raw = np.asarray(inv_fn(pred), dtype=float)
            mase = compute_mase(test_raw.values, pred_raw, train_raw, m=7)
        except Exception:
            mase = np.nan
        results.append((prep, model_name, mase))

# 週次リサンプリング(SARIMAXは除く: 週次にするとsp不要)
prep = "週次"
weekly_series = series.resample("W").mean()
weekly_train = weekly_series.iloc[:-4]
weekly_test = weekly_series.iloc[-4:]
for model_name in ["季節ナイーブ", "SES", "HW加法", "ARIMA"]:
    try:
        pred = forecast_ts(weekly_train, 4, model_name, sp=4)
        mase_w = compute_mase(weekly_test.values, pred, weekly_train, m=4)
    except Exception:
        mase_w = np.nan
    results.append((prep, model_name, mase_w))

df = pd.DataFrame(results, columns=["前処理", "モデル", "MASE"])
df["パイプライン"] = df["前処理"] + " + " + df["モデル"]

import seaborn as sns
pivot = df.pivot_table(index="前処理", columns="モデル", values="MASE")
prep_order = ["なし", "対数", "差分", "週次"]
model_order = ["季節ナイーブ", "SES", "HW加法", "ARIMA", "SARIMAX"]
pivot = pivot.reindex(index=[p for p in prep_order if p in pivot.index],
                      columns=[m for m in model_order if m in pivot.columns])

fig, ax = plt.subplots(figsize=(10, 4))
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 ヒートマップ")
ax.set_xlabel("")
ax.set_ylabel("")
fig.tight_layout()
plt.show()

MASE ヒートマップ

読み方のポイント #

  • 週次の季節性(sp=7)を指定した季節ナイーブ・HW加法・SARIMAXがMASE < 1で安定して優秀。
  • SESとARIMAは曜日パターンを内部的にモデル化しないため、曜日の振れ幅だけ誤差が発生する。
  • 週次リサンプリングは曜日情報を消してしまうため、日次予測が必要な場面では使えない。

5. 誤差パターン分析 #

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

targets = [
    ("なし", "SARIMAX", "#2563eb"),
    ("なし", "ARIMA", "#ef4444"),
    ("なし", "SES", "#f97316"),
]

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

for i, (prep, model_name, color) in enumerate(targets):
    tr_s, inv_fn = preprocess_ts(series, prep)
    train_tr = tr_s.iloc[:len(train_raw)]
    pred = forecast_ts(train_tr, h, model_name, sp=7)
    pred_raw = np.asarray(inv_fn(pred), dtype=float)
    residuals = test_raw.values - pred_raw
    mase = compute_mase(test_raw.values, pred_raw, train_raw, m=7)

    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_raw, "s--", color=color, markersize=3, label="予測")
    ax_l.set_title(f"{prep} + {model_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の残差はホワイトノイズに近いが、ARIMAやSESの残差にはラグ7の自己相関が明確に残る。これは集計指標(MASE)以上に深刻な問題を示している。モデルが系列の構造を取りこぼしているため、予測区間も信頼できない。


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

  • 週次リサンプリングで日次予測を代替する: 曜日の山谷を均してしまうため、特定曜日の需要ピークを捉えられない。
  • sp=7を設定し忘れる: Holt-WintersやSARIMAXでもseasonal_periodsを指定しなければ週次パターンは学習されない。