Hayami Kaito Web
~/tech

時系列予測のしくみ:季節分解・指数平滑法・自己回帰から深層学習まで

published

来月の売上、明日の電力の使用量、来週の来店者数のように、時間の順に並んだデータ(時系列データ)から未来の値を予測することを 時系列予測と呼びます。在庫の発注、人員の配置、発電の計画など、仕事の多くの判断がこの予測に支えられています。

時系列予測は、これまでの記事で扱ってきた機械学習と同じように見えて、大きく違う点があります。データの順番に意味があり、未来のデータは学習に使えないことです。この違いを無視すると、学習のときには高い精度が出るのに、実際の未来の予測ではまったく当たらないモデルができてしまいます。

この記事では、架空の店の月ごとの売上を題材に、時系列予測の基本の考え方と手法を、NumPy で実装しながら説明します。評価の指標は 機械学習の評価指標のしくみ、学習用と検証用のデータの分け方は 過学習と正則化のしくみ で説明したので、ここでは時系列ならではの点に絞ります。

結論を先に書くと、次のようになります。

  • 時系列は「傾向」「季節性」「残り(ノイズ)」に分けて考える
  • まず単純な予測(去年の同じ月の値など)を基準にする。凝ったモデルが、それより良いかで比べる
  • 指数平滑法や自己回帰モデルは、少ない計算で傾向と季節性をとらえる、今も現役の手法
  • 評価では、必ず過去で学習し、未来で答え合わせをする。データをランダムに分けると、未来の情報が混ざって成績が良く見えすぎる
  • 予測は 1 つの値ではなく、幅(予測区間)で示す

説明の途中に出てくる計算例は、Python と NumPy で実際に動かした結果です。

環境

計算例を動かした環境です。

項目バージョン
Python3.13
NumPy2.5

時系列を分解する:傾向・季節性・ノイズ

3 つの成分の足し算として見る

多くの時系列は、次の 3 つの成分の足し算(または掛け算)として見ると理解しやすくなります。

成分意味例
傾向(トレンド)長い期間でのゆっくりした増減店の知名度が上がって、売上が年々伸びる
季節性決まった周期でくり返す変動夏とクリスマスの 12 月に売上が増える
ノイズ傾向と季節性で説明できない残り天気やたまたまの出来事による増減

架空の店の月ごとの売上を 8 年分(96 か月)作り、移動平均を使って傾向と季節性を取り出してみます。

import numpy as np

# 架空の店の月ごとの売上(万円)を 8 年分作る:右肩上がりの傾向 + 夏と 12 月に増える季節性 + ノイズ
rng = np.random.default_rng(0)
months = np.arange(96)
trend = 100 + 1.0 * months
season = np.tile([-10, -12, -5, 0, 3, 8, 15, 18, 5, 0, -6, 20], 8)
sales = trend + season + rng.normal(0, 4, 96)

# 移動平均で傾向を取り出す:12 か月の平均をさらに 2 か月で平均し、ちょうど真ん中の月に合わせる
ma12 = np.convolve(sales, np.ones(12) / 12, mode="valid")
trend_est = np.convolve(ma12, [0.5, 0.5], mode="valid")       # 6 か月目から 89 か月目までの傾向
detrended = sales[6:90] - trend_est
seasonal = np.array([detrended[(np.arange(84) + 6) % 12 == m].mean() for m in range(12)])
seasonal -= seasonal.mean()                                   # 12 か月で合計 0 になるようにそろえる
print(np.round(trend_est[[0, 40, 83]], 1))
print(np.round(seasonal, 1))

実行結果:

[108.7 151.9 192.8]
[-14.  -15.5  -6.6  -4.6  -2.4   7.   13.   18.2   1.9  -4.4 -10.1  17.5]

12 か月の移動平均を取ると、1 年分の季節の上下がならされて、傾向だけが残ります。1 行目は、取り出した傾向の 7 か月目・47 か月目・90 か月目の値で、1 か月に約 1 万円ずつ増えている様子が分かります。

2 行目は、売上から傾向を引いた残りを月ごとに平均した 季節成分(1 月〜12 月)です。データを作るときに使った季節の値は、12 か月の平均(3)を引くと [−13, −15, −8, −3, 0, 5, 12, 15, 2, −3, −9, 17] で、取り出した季節成分はそれに近い値になっています。8 月(18.2)と 12 月(17.5)に売上が増え、1・2 月に減るという形がつかめました。

自己相関:何か月前の値と似ているか

時系列の性質を調べるのによく使われるのが 自己相関です。系列と、それを k か月ずらした系列の相関係数で、「k か月前の値とどれだけ似た動きをするか」を表します。傾向があると、どのずらし方でも相関が高く出てしまうので、前の月との差を取ってから計算します。上のコードの続きです。

def autocorr(x, lag):
    x = x - x.mean()
    return float((x[:-lag] * x[lag:]).sum() / (x * x).sum())

diff = np.diff(sales)  # 前の月との差(傾向を取り除く)
print([round(autocorr(diff, lag), 2) for lag in [1, 3, 6, 12]])

実行結果:

[-0.34, -0.12, -0.07, 0.74]

1・3・6 か月ずらしたときの自己相関は小さいか負ですが、12 か月ずらすと 0.74 と高くなりました。「去年の同じ月と同じような動きをする」という、12 か月の季節性があることが、自己相関から分かります。実際のデータでは、季節性の周期が分からないことも多いので、このように自己相関を調べて周期を見つけます。

まず単純な予測を基準にする

去年の同じ月の値を使う

凝ったモデルを作る前に、誰でも思いつく単純な予測を基準として計算しておくことが大切です。それより良くならないなら、凝ったモデルを使う意味がないからです。最初の 7 年(84 か月)のデータだけを使って、最後の 1 年(12 か月)を予測し、平均絶対誤差(MAE)で比べます。

# 最初の 7 年で作り、最後の 1 年(12 か月)を予測して答え合わせする
train, test = sales[:84], sales[84:]

def mae(pred):
    return round(float(np.abs(pred - test).mean()), 1)

naive = np.full(12, train[-1])                          # 最後の月の値がずっと続く
seasonal_naive = train[-12:]                            # 去年の同じ月の値
drift = (train[-12:].mean() - train[-24:-12].mean())    # 1 年でどれだけ増えたか
seasonal_drift = train[-12:] + drift                    # 去年の同じ月 + 1 年分の伸び
print(mae(naive), mae(seasonal_naive), mae(seasonal_drift))

実行結果:

12.9 12.8 4.1
予測のしかたMAE(万円)
最後の月の値がずっと続く12.9
去年の同じ月の値12.8
去年の同じ月の値 + 1 年分の伸び4.1

「去年の同じ月の値」は季節性をとらえていますが、1 年分の売上の伸びを見落としているので、どの月もおよそ 12 万円少なく予測してしまいます。1 年分の伸びを足すと、MAE は 4.1 まで下がりました。データのノイズの標準偏差は 4 なので、この単純な予測でも、かなり良い予測になっています。

指数平滑法:新しい値ほど重く見る

ホルト・ウィンタース法

指数平滑法は、過去の値の重み付きの平均で予測する方法で、新しい値ほど重く、古い値ほど指数的に軽く扱います。傾向と季節性を持つ時系列には、ホルト・ウィンタース法が使われます。次の 3 つの値を持ち、新しい月の売上が来るたびに少しずつ直していきます。

値意味直す割合
水準季節の影響を除いた、今の売上の高さα
傾き1 か月あたりの増え方β
季節月ごとの上乗せの量γ

予測は「水準 + 何か月先 × 傾き + その月の季節」です。α・β・γ は、テスト用のデータを見ないように、学習用の 7 年のうち最後の 1 年を検証用にして選びます。上のコードの続きです。

def holt_winters(y, alpha, beta, gamma, m=12, horizon=12):
    """ホルト・ウィンタース法(加法型):水準・傾き・季節の 3 つを、新しい値が来るたびに少しずつ直す"""
    level = y[:m].mean()
    slope = (y[m:2 * m].mean() - y[:m].mean()) / m
    season = list(y[:m] - level)
    for t in range(m, len(y)):
        prev_level = level
        level = alpha * (y[t] - season[t - m]) + (1 - alpha) * (level + slope)
        slope = beta * (level - prev_level) + (1 - beta) * slope
        season.append(gamma * (y[t] - level) + (1 - gamma) * season[t - m])
    return np.array([level + (h + 1) * slope + season[len(y) - m + h] for h in range(horizon)])

# 3 つの係数は、学習用のデータの最後の 1 年を検証用にして選ぶ(テスト用のデータは見ない)
best = min(((a, b, g) for a in [0.1, 0.3, 0.5] for b in [0.01, 0.1] for g in [0.1, 0.3, 0.5]),
           key=lambda p: np.abs(holt_winters(train[:72], *p) - train[72:]).mean())
print(best, mae(holt_winters(train, *best)))

実行結果:

(0.1, 0.01, 0.5) 3.7

選ばれた係数は α = 0.1・β = 0.01・γ = 0.5 で、テスト用の 1 年の MAE は 3.7 と、単純な予測の 4.1 より少し良くなりました。水準と傾きは、ゆっくりしか直さない(α・β が小さい)設定が選ばれています。このデータの傾向がまっすぐで変わらないので、1 か月ごとのノイズに振り回されないほうが良いということです。

指数平滑法は計算がとても軽く、何万種類もの商品の需要を毎日予測する、といった場面でも使いやすい方法です。

自己回帰モデル:過去の値の重み付きの和

AR モデル

自己回帰モデル(AR モデル)は、次の値を、直前の p 個の値の重み付きの和で予測する、線形回帰の一種です。

次の値 = c + φ₁ × 1 つ前の値 + φ₂ × 2 つ前の値 + … + φₚ × p 個前の値

傾向や季節性があると、うまく当てはまらないので、まず 差分を取って取り除きます。ここでは「去年の同じ月との差」(季節差分)の系列に、p = 3 の AR モデルを当てはめます。重みは、深層学習以前の機械学習の記事 で説明した最小二乗法で求めます。上のコードの続きです。

# 自己回帰モデル:前の月までの値の重み付きの和で、次の月を予測する
# 傾向と季節性を取り除くため、「去年の同じ月との差」の系列で考える
d12 = train[12:] - train[:-12]
p = 3
X = np.column_stack([np.ones(len(d12) - p)] + [d12[p - k:len(d12) - k] for k in range(1, p + 1)])
coef = np.linalg.lstsq(X, d12[p:], rcond=None)[0]
print([round(float(c), 3) for c in coef])

history_d, history = list(d12), list(train)
pred = []
for h in range(12):                                     # 1 か月先を予測し、その予測を使ってさらに先を予測する
    next_d = coef[0] + sum(coef[k] * history_d[-k] for k in range(1, p + 1))
    history_d.append(next_d)
    history.append(history[-12] + next_d)               # 差を元の売上に戻す
    pred.append(history[-1])
print(mae(np.array(pred)))

実行結果:

[10.348, 0.22, -0.056, -0.004]
4.1

1 行目は、定数 c と 3 つの重み φ₁・φ₂・φ₃ です。定数が 10.348 と大きく、重みは小さくなっています。定数を (1 − 重みの合計) で割ると約 12.3 で、これが「去年の同じ月との差」の平均、つまり 1 年で約 12 万円伸びるという傾向にあたります。前の月の差からの影響は弱く、「去年の同じ月より 12 万円ほど多い」というのが、このモデルの見つけた規則です。MAE は 4.1 で、「去年の同じ月 + 1 年分の伸び」とほぼ同じ予測になりました。

12 か月先まで予測するとき、2 か月先の予測には 1 か月先の「予測値」を使います。このように、予測を入力にしてさらに先を予測するので、先になるほど誤差が積み重なりやすくなります。

AR モデルに、過去の予測の外れ(誤差)の重み付きの和を加えたものが ARMA、差分を取る手順まで含めたものが ARIMA、季節差分も含めたものが SARIMA で、1970 年のボックスとジェンキンスの本以来、時系列予測の標準的な手法になっています。

時系列の評価:未来の情報を混ぜない

ランダムに分けると成績が良く見えすぎる

時系列で最も気をつけるべきなのが、データの分け方です。株価のように、毎日前の日の値にランダムな変化が加わる系列(ランダムウォーク)を作り、「日付が近い 5 日の値の平均」で予測するモデルを、2 通りの分け方で評価してみます。

import numpy as np

# 株価のような「ランダムウォーク」:毎日、前の日の値にランダムな変化を足す
rng = np.random.default_rng(0)
price = 1000 + np.cumsum(rng.normal(0, 10, 1000))
day = np.arange(1000)

def knn_predict(train_days, test_days, k=5):
    """日付が近い k 日の値の平均で予測する"""
    preds = []
    for d in test_days:
        nearest = train_days[np.argsort(np.abs(train_days - d))[:k]]
        preds.append(price[nearest].mean())
    return np.array(preds)

# 1. ランダムに 8 割を学習用、2 割をテスト用に分ける
shuffled = rng.permutation(1000)
tr, te = np.sort(shuffled[:800]), np.sort(shuffled[800:])
print(round(float(np.abs(knn_predict(tr, te) - price[te]).mean()), 1))

# 2. 時間の順に、最初の 8 割で学習し、あとの 2 割(未来)を予測する
tr, te = day[:800], day[800:]
print(round(float(np.abs(knn_predict(tr, te) - price[te]).mean()), 1))
print(round(float(np.abs(price[799] - price[te]).mean()), 1))  # 最後の日の値がずっと続くと予測した場合

実行結果:

6.9
77.7
72.3

ランダムに分けると、MAE は 6.9 と、1 日の変化の大きさ(標準偏差 10)より小さくなりました。テスト用の日の前後の日が学習用に入っているので、「前の日と次の日の間」を埋めるだけで当たってしまうのです。ところが、時間の順に分けて本当に未来を予測させると、MAE は 77.7 と 10 倍以上に悪化しました。「最後の日の値がずっと続く」という単純な予測(72.3)にも負けています。

ランダムに分けた評価では、実際には手に入らない未来の情報を使って答えているので、成績が良く見えすぎます。時系列では、必ず「過去で学習し、未来で答え合わせ」をします。同じように、特徴を作るときに未来の情報(その月の平均など、あとにならないと分からない値)を混ぜてしまう間違いも、時系列でよく起きる リーク(情報の漏れ)です。

ローリング評価:予測を始める時点をずらしていく

テスト用の期間が 1 つだけだと、その期間がたまたま予測しやすかったのかどうかが分かりません。そこで、予測を始める時点を 1 か月ずつずらしながら、「その時点までのデータで学習 → その先を予測 → 答え合わせ」をくり返して平均します。これを ローリング評価(ウォークフォワード評価)と呼びます。売上のデータで、ホルト・ウィンタース法を使い、1・6・12 か月先の予測の外れ方を調べてみます。最初の売上のコードの続きで、テスト用の 1 年には触れず、学習用の 7 年の中だけで行います。

# 予測を始める月を 1 か月ずつずらしながら、何か月先の予測でどれだけ外れるかを測る(学習用の 7 年の中だけで)
errors = {1: [], 6: [], 12: []}
for origin in range(48, 84):
    fc = holt_winters(train[:origin], *best)
    for h in errors:
        if origin + h <= 84:
            errors[h].append(fc[h - 1] - train[origin + h - 1])
for h, e in errors.items():
    e = np.array(e)
    print(h, len(e), round(float(np.abs(e).mean()), 1), round(float(e.std()), 1))

# 1 か月先の予測の外れ方の標準偏差から、95% の予測区間を作り、本当に 95% 入るか確かめる
sigma = np.std(errors[1])
fc = holt_winters(train, *best)
inside = np.abs(test - fc) <= 1.96 * sigma                     # 予測 ± 1.96σ の中に入ったか
print(round(float(sigma), 1), int(inside.sum()), len(test))

実行結果:

1 36 3.9 4.8
6 31 3.9 4.8
12 25 3.9 4.7
4.8 11 12

1〜3 行目は、何か月先の予測か、答え合わせの回数、MAE、外れの標準偏差です。このデータは傾向も季節性も一定に作ってあるので、1 か月先でも 12 か月先でも外れ方はほとんど変わりませんでした。実際のデータでは、傾向が途中で変わったり、予想外の出来事が起きたりするので、先の予測ほど外れが大きくなるのがふつうです。

予測区間:幅で示す

予測は、「来月の売上は 200 万円」という 1 つの値だけでなく、「95% の確率で 190〜210 万円」のような 予測区間で示すと、使う人が判断しやすくなります。在庫をどれだけ多めに持つかは、予測の幅で決まるからです。

最後の行は、ローリング評価で求めた 1 か月先の外れの標準偏差 σ = 4.8 から、「予測 ± 1.96σ」の区間を作り、テスト用の 12 か月の実際の売上がその中に何か月入ったかです。12 か月中 11 か月(92%)が区間に入り、目標の 95% に近い結果になりました。区間の幅を決めるのにも、テスト用のデータではなく、学習用のデータの中のローリング評価を使っていることに注意してください。

機械学習・深層学習による時系列予測

特徴を作って回帰の問題にする

時系列予測は、「過去の値(1 か月前・12 か月前の売上など)・曜日・月・祝日・天気・値引きの有無」などを特徴にした回帰の問題として、深層学習以前の機械学習の記事 で説明した勾配ブースティングで解くこともできます。2020 年に行われた、ウォルマートの 3 万種類以上の商品の売上を予測する国際的なコンペティション(M5)では、上位の多くが LightGBM を使った方法でした。

深層学習のモデル

深層学習でも、時系列予測のモデルがたくさん作られています。

モデル考え方
RNN・LSTM過去の値を 1 つずつ読み、隠れ状態にまとめて次を予測する
DeepARたくさんの似た時系列(多くの商品の売上など)をまとめて 1 つの RNN で学習し、確率の分布として予測する
Temporal Fusion Transformerアテンションで、どの時点・どの特徴が予測に効いたかも示す
PatchTST時系列を短い区間(パッチ)に区切り、Transformer で処理する
時系列の基盤モデル(Chronos・TimesFM など)大量の時系列で事前に学習し、新しい時系列でも追加の学習なしで予測する

ただし、2022 年の研究では、長期の予測で、当時の Transformer の多くのモデルが、とても単純な線形のモデルに負けることが示されました。時系列予測では、深層学習のモデルがいつも勝つわけではなく、この記事で説明した単純な予測・指数平滑法・ARIMA と比べてから使うことが大切です。

なお、感染症の広がりのように、しくみ(人から人への感染)が分かっている現象では、データの形だけを学ぶのではなく、しくみを式にした 数理モデルで予測する方法もあります。

このサイトの 感染症の数理モデル(SIR) では、感染のしくみを表す SIR・SEIR モデルを解いて、基本再生産数やワクチン・対策で流行の山がどう変わるかを確かめられます。

時系列予測の手法の流れ

この記事で紹介した手法を年代順に並べます。

年手法・出来事ポイント
1956〜1960指数平滑法(ブラウン・ホルト・ウィンタース)新しい値ほど重く見て、水準・傾き・季節を直していく
1970ARIMA(ボックス・ジェンキンス)差分を取り、自己回帰と移動平均で予測する
1990STL局所的な回帰で、傾向・季節性・残りに分解する
2000ローリング評価の整理予測を始める時点をずらして、未来で評価する方法をまとめる
2017DeepAR・Prophetたくさんの時系列をまとめて学ぶ RNN。傾向・季節性・祝日を組み合わせる実用的なモデル
2018M4 コンペティション指数平滑法と RNN を組み合わせた方法が優勝する
2019Temporal Fusion Transformerアテンションを使い、効いた特徴も示す
2020M5 コンペティション勾配ブースティング(LightGBM)を使った方法が上位を占める
2022「Transformer は時系列予測に有効か」単純な線形のモデルが多くの Transformer のモデルを上回る
2023〜2024PatchTST・時系列の基盤モデルパッチに区切る Transformer。事前学習した大きなモデルで予測する

まとめ

  • 時系列は、傾向・季節性・ノイズに分けて考える。移動平均で傾向を、月ごとの平均で季節成分を取り出せる
  • 自己相関を調べると、季節性の周期が分かる
  • まず「去年の同じ月 + 1 年分の伸び」のような単純な予測を基準にし、凝ったモデルがそれより良いかで比べる
  • ホルト・ウィンタース法は、水準・傾き・季節の 3 つを新しい値が来るたびに直していく、軽くて実用的な方法
  • 自己回帰モデルは、差分を取った系列を、過去の値の重み付きの和で予測する。先の予測ほど誤差が積み重なる
  • 時系列をランダムに分けて評価すると、未来の情報が混ざって成績が良く見えすぎる。必ず過去で学習し、未来で答え合わせをする
  • ローリング評価で予測を始める時点をずらして評価し、外れの大きさから予測区間を作る
  • 勾配ブースティングや深層学習のモデルも使われるが、いつも単純な手法に勝つわけではない

参考

  • R. G. Brown, "Exponential Smoothing for Predicting Demand", 1956
  • C. C. Holt, "Forecasting seasonals and trends by exponentially weighted moving averages", ONR Memorandum 1957(International Journal of Forecasting 2004 に再録)
  • P. R. Winters, "Forecasting Sales by Exponentially Weighted Moving Averages", Management Science 1960
  • G. E. P. Box and G. M. Jenkins, "Time Series Analysis: Forecasting and Control", Holden-Day 1970
  • R. B. Cleveland et al., "STL: A Seasonal-Trend Decomposition Procedure Based on Loess", Journal of Official Statistics 1990
  • L. J. Tashman, "Out-of-sample tests of forecasting accuracy: an analysis and review", International Journal of Forecasting 2000
  • S. J. Taylor and B. Letham, "Forecasting at Scale", The American Statistician 2018
  • S. Makridakis et al., "The M4 Competition: 100,000 time series and 61 forecasting methods", International Journal of Forecasting 2020
  • D. Salinas et al., "DeepAR: Probabilistic forecasting with autoregressive recurrent networks", International Journal of Forecasting 2020(arXiv:1704.04110)
  • B. Lim et al., "Temporal Fusion Transformers for Interpretable Multi-horizon Time Series Forecasting", International Journal of Forecasting 2021(arXiv:1912.09363)
  • R. J. Hyndman and G. Athanasopoulos, "Forecasting: Principles and Practice", 3rd ed., OTexts 2021(otexts.com/fpp3)
  • S. Makridakis et al., "M5 accuracy competition: Results, findings, and conclusions", International Journal of Forecasting 2022
  • A. Zeng et al., "Are Transformers Effective for Time Series Forecasting?", AAAI 2023(arXiv:2205.13504)
  • Y. Nie et al., "A Time Series is Worth 64 Words: Long-term Forecasting with Transformers", ICLR 2023(arXiv:2211.14730)
  • A. F. Ansari et al., "Chronos: Learning the Language of Time Series", TMLR 2024(arXiv:2403.07815)
  • A. Das et al., "A decoder-only foundation model for time-series forecasting", ICML 2024(arXiv:2310.10688)