Transformerによる時系列予測 — Self-Attentionで時間依存性を捉える

LSTMやGRUは時系列データの長期依存性を学習する強力なモデルですが、根本的な制約を2つ抱えています。第一に、逐次処理です。時刻 $t$ の隠れ状態を計算するには時刻 $t-1$ の結果が必要であり、系列を並列に処理できません。第二に、間接的な長距離依存です。100ステップ前の情報を利用するには、100回のゲート操作を経て情報が伝達される必要があります。

伝言ゲームを想像してみてください。100人が一列に並んで最初の人から最後の人にメッセージを伝えると、途中で情報が歪んでしまいます。LSTMのゲート機構は「途中で情報を忘れにくくする」工夫ですが、根本的に間接的な伝達であることは変わりません。

もし、列の全員が同じ部屋にいて、誰もが任意の他の人と直接会話できたらどうでしょうか。これがTransformerのSelf-Attention機構が実現する世界です。任意の2時点間の依存関係を1ステップで直接的に捉えられ、しかも全時点を並列に処理できます。

2017年にVaswaniらが「Attention Is All You Need」で提案したTransformerは、自然言語処理に革命を起こしましたが、その利点は時系列予測にもそのまま活きます。

Transformerの時系列予測への応用を理解すると、以下のことが可能になります。

  • 長期予測: 数百ステップ以上の長距離依存関係を直接的に捉える時系列予測
  • 高速な学習: GPU並列化を最大限に活用した効率的な訓練
  • 解釈性: Attention重みによる「どの過去の時点が予測に重要か」の可視化
  • 最新モデルの理解: Informer、Autoformer、PatchTSTなど時系列特化Transformerの基盤

本記事の内容

  • RNN系モデルの限界とTransformerの利点
  • Self-Attentionの時系列への適用とCausal Mask
  • 位置エンコーディングの時系列版
  • Encoder-onlyアーキテクチャによる時系列予測の設計
  • PyTorchでの実装
  • 合成データ+実データ(airline passengers)での実験
  • RNN/LSTMとの比較

前提知識

この記事を読む前に、以下の記事を読んでおくと理解が深まります。

RNN系モデルの限界

逐次処理のボトルネック

RNN系のモデル(バニラRNN、LSTM、GRU)は、本質的に逐次処理です。時刻 $t$ の計算は

$$ \bm{h}_t = f(\bm{h}_{t-1}, \bm{x}_t) $$

という再帰的な形をしており、$\bm{h}_1, \bm{h}_2, \ldots, \bm{h}_T$ は順番にしか計算できません。系列長 $T$ の入力を処理するには $O(T)$ の逐次ステップが必要です。

GPUは数千のコアで並列計算を行うことに最適化されていますが、RNNの逐次的な計算はこの並列性を活用できません。系列長が長くなるほど、GPUの計算リソースが遊んでしまう非効率な状況が生じます。

長距離依存の間接性

LSTMのゲート機構は勾配消失問題を大幅に緩和しますが、情報の伝達経路の長さは変わりません。時刻 $1$ の情報が時刻 $T$ に届くまでの経路長は $O(T)$ です。

情報理論的に考えると、$T$ 回のゲート操作を経るたびに情報のエントロピーが減少する可能性があります。忘却ゲートが完璧($f_t = 1$)であれば情報は保存されますが、実際の学習では $f_t < 1$ となることが多く、長い系列では情報が劣化しがちです。

Self-Attentionによる解決

Transformerの Self-Attention機構は、これらの問題を根本的に解決します。

並列処理: Self-Attentionの計算は

$$ \text{Attention}(\bm{Q}, \bm{K}, \bm{V}) = \text{softmax}\!\left(\frac{\bm{Q}\bm{K}^\top}{\sqrt{d_k}}\right)\bm{V} $$

であり、行列積とsoftmaxで構成されています。これらは全て並列計算可能であり、GPUの性能をフルに活用できます。

直接的な依存関係: Attention行列 $\bm{A} = \text{softmax}(\bm{Q}\bm{K}^\top/\sqrt{d_k})$ の要素 $A_{ij}$ は、時刻 $i$ から時刻 $j$ への直接的な依存度を表します。任意の2時点間の経路長は $O(1)$ であり、長距離の依存関係を1ステップで捉えられます。

特性 RNN LSTM Transformer
並列計算 不可 不可 可能
最大経路長 $O(T)$ $O(T)$ $O(1)$
計算量/層 $O(T \cdot d^2)$ $O(T \cdot d^2)$ $O(T^2 \cdot d)$
長距離依存 勾配消失 ゲートで緩和 直接的

ここで注意すべき点があります。Transformerの1層あたりの計算量は $O(T^2 \cdot d)$ であり、系列長 $T$ に対して二乗のコストがかかります。RNN系の $O(T \cdot d^2)$ と比較すると、長い系列では計算量が大きくなる可能性があります。ただし、Transformerは並列計算が可能なため、実際のウォールクロック時間ではGPU上でRNNより高速になることが多いです。

では、Self-Attentionを時系列予測にどのように適用するかを見ていきましょう。

Self-Attentionの時系列への適用

因果的制約(Causal Constraint)

時系列予測には、自然言語処理にはない重要な制約があります。それは因果性です。時刻 $t$ の予測には、時刻 $t$ 以前の情報しか使えません。未来の情報を使った予測は「カンニング」であり、実用上意味がありません。

通常のSelf-Attentionでは、各時刻が全ての時刻に注目できます。

$$ A_{ij} = \frac{\exp(q_i^\top k_j / \sqrt{d_k})}{\sum_{l=1}^{T} \exp(q_i^\top k_l / \sqrt{d_k})} $$

この式では、時刻 $i$ は $j > i$(未来の時刻)にも注目できてしまいます。時系列予測では、これを防ぐ必要があります。

Causal Mask

因果性を強制するために、Causal Mask(因果マスク)を導入します。

マスク行列 $\bm{M} \in \mathbb{R}^{T \times T}$ を次のように定義します。

$$ M_{ij} = \begin{cases} 0 & \text{if } j \leq i \\ -\infty & \text{if } j > i \end{cases} $$

この定義は直感的です。$j \leq i$(過去から現在まで)のときはマスクなし($0$)、$j > i$(未来)のときは $-\infty$ でマスクします。マスクをAttentionスコアに加算すると、

$$ \text{Attention}(\bm{Q}, \bm{K}, \bm{V}) = \text{softmax}\!\left(\frac{\bm{Q}\bm{K}^\top}{\sqrt{d_k}} + \bm{M}\right)\bm{V} $$

softmaxに入る前に未来の時刻のスコアが $-\infty$ になるため、softmaxの出力は $0$ になります。これにより、各時刻は過去と現在の情報のみに注目し、未来の情報は完全に遮断されます。

具体的に $T = 4$ の場合のCausal Maskは

$$ \bm{M} = \begin{pmatrix} 0 & -\infty & -\infty & -\infty \\ 0 & 0 & -\infty & -\infty \\ 0 & 0 & 0 & -\infty \\ 0 & 0 & 0 & 0 \end{pmatrix} $$

です。1行目(時刻1)は自分自身のみに注目でき、4行目(時刻4)は全時刻に注目できます。この下三角行列の構造は、時間の因果性を反映しています。

時系列におけるSelf-Attentionの解釈

時系列データに対するSelf-Attentionの出力を考えてみましょう。Causal Maskを適用した後のAttention重み $\bm{A}$ の $i$ 行目は、時刻 $i$ の出力を計算するために、過去の各時刻にどれだけ「注目」するかを表す確率分布です。

$$ \bm{z}_i = \sum_{j=1}^{i} A_{ij} \bm{v}_j $$

たとえば、電力需要の時系列データを処理するとき、月曜日の朝の時刻 $i$ は、前週の月曜日の朝(同じ曜日パターン)に高い Attention重みを割り当てるかもしれません。これは、RNNでは捉えにくい「7日周期の依存関係」をSelf-Attentionが直接的に学習できることを意味します。

Attention重みは可視化可能であるため、「モデルがどの過去の時点を重要視しているか」を人間が解釈できるという利点もあります。

Self-Attentionの因果マスクの仕組みが理解できたところで、次に「入力の順序情報」をどう扱うかを考えましょう。Self-Attention自体は入力の順序を区別できないため、位置エンコーディングが必要です。

位置エンコーディングの時系列版

なぜ位置エンコーディングが必要か

Self-Attentionの計算 $\text{softmax}(\bm{Q}\bm{K}^\top/\sqrt{d_k})\bm{V}$ は、入力の順列に対して等変です。つまり、入力の時点を入れ替えても(Causal Maskを除けば)各時点の出力値は変わりません。

しかし時系列データでは、「3時間前のデータ」と「3日前のデータ」は全く異なる意味を持ちます。時間的な近さ・遠さの情報をモデルに提供するために、位置エンコーディングが不可欠です。

Sinusoidal位置エンコーディング

最も基本的な位置エンコーディングは、Vaswaniらが提案したsin/cosベースの方法です。

位置 $\text{pos}$ の次元 $i$ に対して、

$$ \begin{align} \text{PE}(\text{pos}, 2i) &= \sin\!\left(\frac{\text{pos}}{10000^{2i/d}}\right) \\ \text{PE}(\text{pos}, 2i+1) &= \cos\!\left(\frac{\text{pos}}{10000^{2i/d}}\right) \end{align} $$

各次元が異なる周波数を持つため、位置情報が多スケールで表現されます。低次元は短い周期、高次元は長い周期に対応します。

時系列データに対する位置エンコーディングの特性

時系列データに対して、このsin/cosエンコーディングは特に好都合な性質を持っています。

相対位置の表現: 任意の固定オフセット $k$ に対して、$\text{PE}(\text{pos} + k)$ は $\text{PE}(\text{pos})$ の線形変換で表されます。

$$ \begin{pmatrix} \sin(\omega(\text{pos}+k)) \\ \cos(\omega(\text{pos}+k)) \end{pmatrix} = \begin{pmatrix} \cos(\omega k) & \sin(\omega k) \\ -\sin(\omega k) & \cos(\omega k) \end{pmatrix} \begin{pmatrix} \sin(\omega \cdot \text{pos}) \\ \cos(\omega \cdot \text{pos}) \end{pmatrix} $$

ここで三角関数の加法定理を適用すると、位置のシフト $k$ が回転行列で表現されています。この線形関係により、Self-Attentionは「2つの時点の間隔が何ステップか」という相対的な情報を容易に学習できます。

周期性の自然な表現: 時系列データには日周期(24時間)、週周期(7日)、年周期(365日)など、複数の周期成分が含まれることが多いです。sin/cosエンコーディングの多周波数構造は、これらの周期パターンと自然に対応します。

外挿性: sin/cos関数は訓練時に見た系列長を超えた位置に対しても有意な値を返すため、訓練時より長い系列に対する推論(外挿)が原理的に可能です。

入力埋め込みへの加算

入力の時系列値 $x_t \in \mathbb{R}$ をまず線形層で $d$ 次元に射影し、そこに位置エンコーディングを加算します。

$$ \bm{e}_t = \bm{W}_{\text{emb}} x_t + \bm{b}_{\text{emb}} + \text{PE}(t) $$

$\bm{W}_{\text{emb}} \in \mathbb{R}^{d \times 1}$ は入力を $d$ 次元に射影する重み行列です。加算(足し算)で位置情報を混合する理由は、元の情報を破壊せずに位置の手がかりを追加するためです。

位置情報を入力に付与する方法が決まったところで、次はTransformerを時系列予測用のアーキテクチャとしてどう設計するかを見ていきましょう。

Encoder-onlyアーキテクチャの設計

なぜEncoder-onlyか

Transformerの原論文ではEncoder-Decoderアーキテクチャが提案されましたが、時系列予測ではEncoder-onlyアーキテクチャ(Causal Mask付き)がシンプルで効果的です。

Encoder-Decoderは機械翻訳のように入力系列と出力系列の構造が異なるタスクに適していますが、時系列の次ステップ予測では入力と出力が同じ時系列上にあります。この場合、Causal Maskを適用したEncoder-only構造で十分であり、GPTと同じアプローチです。

アーキテクチャの全体像

本記事で実装するEncoder-only Transformerの構造は以下の通りです。

  1. 入力埋め込み層: スカラー値 $x_t$ を $d$次元ベクトルに線形射影
  2. 位置エンコーディング: sin/cosの位置情報を加算
  3. Transformer Encoderブロック $\times N$: Multi-Head Self-Attention + Feed-Forward Network
  4. 出力層: $d$次元から1次元(予測値)への線形射影

各Transformer Encoderブロックは、以下の2つのサブ層で構成されます。

サブ層1: Multi-Head Causal Self-Attention

$$ \text{MultiHead}(\bm{Q}, \bm{K}, \bm{V}) = \text{Concat}(\text{head}_1, \ldots, \text{head}_H)\bm{W}_O $$

$$ \text{head}_i = \text{Attention}(\bm{Q}\bm{W}_{Q_i}, \bm{K}\bm{W}_{K_i}, \bm{V}\bm{W}_{V_i}) $$

ここで $H$ はヘッド数、$\bm{W}_O \in \mathbb{R}^{Hd_v \times d}$ は出力射影です。Causal Maskが適用されるため、各時点は過去の情報のみを参照します。

サブ層2: Position-wise Feed-Forward Network

$$ \text{FFN}(\bm{x}) = \text{ReLU}(\bm{x}\bm{W}_1 + \bm{b}_1)\bm{W}_2 + \bm{b}_2 $$

$\bm{W}_1 \in \mathbb{R}^{d \times d_{ff}}$、$\bm{W}_2 \in \mathbb{R}^{d_{ff} \times d}$ で、通常 $d_{ff} = 4d$ です。

各サブ層の前後に残差接続(Residual Connection)と層正規化(Layer Normalization)が適用されます。

$$ \text{output} = \text{LayerNorm}(\bm{x} + \text{SubLayer}(\bm{x})) $$

残差接続により、勾配が深いネットワークを通じても安定的に伝播します。ResNetの skip connection と同じ原理です。

Multi-Head Attentionの利点

なぜAttentionを複数のヘッドに分割するのでしょうか。単一のAttentionヘッドは、1つの「注目パターン」しか学習できません。時系列データでは、短期的なトレンド、長期的な周期性、特定の曜日パターンなど、複数の異なる依存関係が同時に存在します。

Multi-Head Attentionでは、各ヘッドが異なる種類の依存関係を専門的に学習できます。たとえば、ヘッド1は直近数ステップの短期トレンドに注目し、ヘッド2は1週間前の同じ時間帯に注目し、ヘッド3は全体的な上昇・下降トレンドに注目する、といった分業が可能です。

アーキテクチャの設計が固まったところで、いよいよPyTorchでの実装に進みましょう。

PyTorchでの実装

位置エンコーディングの実装

import torch
import torch.nn as nn
import numpy as np
import matplotlib.pyplot as plt

class PositionalEncoding(nn.Module):
    def __init__(self, d_model, max_len=5000):
        super().__init__()
        pe = torch.zeros(max_len, d_model)
        position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1)
        div_term = torch.exp(
            torch.arange(0, d_model, 2).float() * (-np.log(10000.0) / d_model)
        )
        pe[:, 0::2] = torch.sin(position * div_term)
        pe[:, 1::2] = torch.cos(position * div_term)
        pe = pe.unsqueeze(0)  # (1, max_len, d_model)
        self.register_buffer('pe', pe)

    def forward(self, x):
        # x: (batch_size, seq_len, d_model)
        return x + self.pe[:, :x.size(1), :]

PositionalEncoding クラスでは、コンストラクタ内であらかじめ全位置のエンコーディングを計算しておき、register_buffer でモデルのパラメータとは別に保持します。forward では入力テンソルにブロードキャストで加算します。div_term の計算は $1/10000^{2i/d}$ を数値安定のために対数空間で行っています。

Transformerモデルの実装

class TimeSeriesTransformer(nn.Module):
    def __init__(self, d_model=64, nhead=4, num_layers=2,
                 d_ff=256, dropout=0.1):
        super().__init__()
        self.d_model = d_model

        # 入力埋め込み(スカラー -> d_model次元)
        self.input_proj = nn.Linear(1, d_model)
        self.pos_encoder = PositionalEncoding(d_model)

        # Transformer Encoderブロック
        encoder_layer = nn.TransformerEncoderLayer(
            d_model=d_model,
            nhead=nhead,
            dim_feedforward=d_ff,
            dropout=dropout,
            batch_first=True
        )
        self.transformer_encoder = nn.TransformerEncoder(
            encoder_layer, num_layers=num_layers
        )

        # 出力層(d_model次元 -> スカラー)
        self.output_proj = nn.Linear(d_model, 1)

    def _generate_causal_mask(self, sz):
        """Causal Mask: 上三角を -inf で埋める"""
        mask = torch.triu(torch.ones(sz, sz), diagonal=1)
        mask = mask.masked_fill(mask == 1, float('-inf'))
        return mask

    def forward(self, x):
        # x: (batch_size, seq_len, 1)
        x = self.input_proj(x) * np.sqrt(self.d_model)
        x = self.pos_encoder(x)

        # Causal Mask の生成
        causal_mask = self._generate_causal_mask(x.size(1)).to(x.device)

        # Transformer Encoder
        out = self.transformer_encoder(x, mask=causal_mask)

        # 出力射影
        out = self.output_proj(out)  # (batch_size, seq_len, 1)
        return out

TimeSeriesTransformer クラスの設計ポイントを確認しましょう。input_proj でスカラー値を $d_{\text{model}}$ 次元に射影する際に $\sqrt{d_{\text{model}}}$ を掛けています。これは位置エンコーディング(振幅が $[-1, 1]$)に対して入力の埋め込みが小さすぎないようにスケーリングするためで、原論文に倣った設計です。_generate_causal_mask は上三角行列を $-\infty$ で埋めた Causal Mask を生成します。

PyTorchの nn.TransformerEncoderLayer は、Multi-Head Self-Attention、Feed-Forward Network、残差接続、Layer Normalization を全て内包しており、上で解説した数式がそのまま実装されています。

学習ユーティリティの実装

def create_sequences(data, seq_len):
    """時系列データから入力・ターゲットの系列ペアを作成"""
    X, Y = [], []
    for i in range(len(data) - seq_len):
        X.append(data[i:i+seq_len])
        Y.append(data[i+1:i+seq_len+1])
    X = torch.FloatTensor(np.array(X)).unsqueeze(-1)  # (N, seq_len, 1)
    Y = torch.FloatTensor(np.array(Y)).unsqueeze(-1)
    return X, Y

def train_model(model, X_train, Y_train, n_epochs=100, batch_size=32, lr=0.001):
    """モデルの学習ループ"""
    optimizer = torch.optim.Adam(model.parameters(), lr=lr)
    criterion = nn.MSELoss()
    losses = []
    n_samples = len(X_train)

    for epoch in range(n_epochs):
        model.train()
        epoch_loss = 0
        # ミニバッチ学習
        indices = torch.randperm(n_samples)
        for start in range(0, n_samples, batch_size):
            end = min(start + batch_size, n_samples)
            batch_idx = indices[start:end]
            X_batch = X_train[batch_idx]
            Y_batch = Y_train[batch_idx]

            optimizer.zero_grad()
            output = model(X_batch)
            loss = criterion(output, Y_batch)
            loss.backward()
            torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
            optimizer.step()
            epoch_loss += loss.item() * (end - start)

        epoch_loss /= n_samples
        losses.append(epoch_loss)
        if (epoch + 1) % 20 == 0:
            print(f"Epoch {epoch+1}/{n_epochs}, Loss: {epoch_loss:.6f}")

    return losses

train_model 関数では、Adam optimizer、MSE損失関数、勾配クリッピング(ノルム上限1.0)を使用しています。ミニバッチ学習でシャッフルしたインデックスを使い、各エポックで全データを1回ずつ処理します。

これで実装が完成しました。次に、合成データと実データの両方で実験を行い、Transformerの時系列予測能力を検証しましょう。

合成データでの実験

複合正弦波による基本性能の検証

まず、LSTMの記事と同じ複合正弦波データでTransformerの予測能力を検証します。

import torch
import numpy as np
import matplotlib.pyplot as plt

torch.manual_seed(42)
np.random.seed(42)

# データ生成
t = np.linspace(0, 20 * np.pi, 2000)
data = np.sin(t) + 0.1 * np.sin(3 * t)

# 訓練・テストデータの作成
seq_len = 50
X, Y = create_sequences(data[:1200], seq_len)
X_test, Y_test = create_sequences(data[1200:], seq_len)

print(f"訓練サンプル数: {len(X)}")
print(f"テストサンプル数: {len(X_test)}")
print(f"系列長: {seq_len}")
# Transformerモデルの初期化と学習
model = TimeSeriesTransformer(
    d_model=64, nhead=4, num_layers=2, d_ff=256, dropout=0.1
)
print(f"モデルパラメータ数: {sum(p.numel() for p in model.parameters()):,}")

losses = train_model(model, X, Y, n_epochs=100, batch_size=32, lr=0.001)

損失の推移

plt.figure(figsize=(10, 4))
plt.plot(losses, color='steelblue', linewidth=1.5)
plt.xlabel('Epoch')
plt.ylabel('MSE Loss')
plt.title('Transformer Training Loss on Composite Sine Wave')
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()

損失が学習とともに滑らかに減少していく様子が確認できます。Transformerは最初の数エポックでは損失の減少が比較的遅いことがありますが、これはAttention重みの初期化がランダムであるため、有意な依存関係を捉えるまでに数エポックかかるためです。その後は急激に損失が下がり、RNNベースのモデルと同等以下の損失に到達します。

テストデータでの予測

model.eval()
with torch.no_grad():
    pred = model(X_test)

# 可視化
test_actual = Y_test[:, -1, 0].numpy()  # 各系列の最後の予測値
test_pred = pred[:, -1, 0].numpy()

plt.figure(figsize=(12, 5))
n_plot = min(300, len(test_actual))
plt.plot(range(n_plot), test_actual[:n_plot],
         label='Ground Truth', color='steelblue', linewidth=2)
plt.plot(range(n_plot), test_pred[:n_plot],
         label='Transformer Prediction', color='coral', linewidth=2, linestyle='--')
plt.xlabel('Time Step')
plt.ylabel('Value')
plt.title('Transformer Prediction vs Ground Truth')
plt.legend(fontsize=12)
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()

test_mse = np.mean((test_actual[:n_plot] - test_pred[:n_plot])**2)
print(f"Test MSE: {test_mse:.6f}")

予測結果のグラフから、Transformerが複合正弦波のパターンを精確に追跡していることが確認できます。基本周波数と3倍高調波の両方を正確に再現しており、特に位相のずれがほとんどない点が注目に値します。Self-Attentionが系列長50の窓の中で、各時点の周期的な依存関係を直接的に捉えていることの証拠です。

Attention重みの可視化

Transformerの強みの一つは、Attention重みを可視化することで「モデルが何に注目しているか」を解釈できることです。

# Attention重みの取得
model.eval()

# フック関数でAttention重みをキャプチャ
attention_weights = []

def hook_fn(module, input, output):
    # TransformerEncoderLayerの内部のself_attn
    pass

# 手動でAttention重みを計算
sample = X_test[:1]  # 1サンプルを取得
with torch.no_grad():
    x = model.input_proj(sample) * np.sqrt(model.d_model)
    x = model.pos_encoder(x)
    causal_mask = model._generate_causal_mask(x.size(1))

    # 最初のEncoder層のAttention重みを手動計算
    layer = model.transformer_encoder.layers[0]
    # Self-Attention
    q = k = v = x
    attn_output, attn_weights_tensor = layer.self_attn(
        q, k, v, attn_mask=causal_mask, need_weights=True,
        average_attn_weights=False
    )

attn = attn_weights_tensor[0].numpy()  # (nhead, seq_len, seq_len)

fig, axes = plt.subplots(1, 4, figsize=(16, 4))
for i in range(4):
    im = axes[i].imshow(attn[i], aspect='auto', cmap='Blues')
    axes[i].set_title(f'Head {i+1}')
    axes[i].set_xlabel('Key Position')
    axes[i].set_ylabel('Query Position')
    plt.colorbar(im, ax=axes[i], fraction=0.046)
plt.suptitle('Attention Weights (Layer 1)', fontsize=14)
plt.tight_layout()
plt.show()

Attention重みのヒートマップから、いくつかの興味深いパターンが読み取れます。まず、下三角行列の構造が明確に見え、Causal Maskが正しく機能していることがわかります(上三角部分は完全に0)。各ヘッドが異なるAttentionパターンを学習していることも確認できます。あるヘッドは直近の数ステップに集中的に注目し(対角線付近が明るい)、別のヘッドは一定間隔で周期的に注目する(縦方向に繰り返しパターン)可能性があります。これはMulti-Head Attentionの設計意図通りの動作であり、異なる種類の時間依存性を複数のヘッドで分業的に捉えていることを示しています。

合成データでの基本性能が確認できたので、次により現実的なデータセットでTransformerの性能を検証しましょう。

実データ(Airline Passengers)での実験

データセットの概要

Airline Passengersデータセットは、1949年1月から1960年12月までの月ごとの航空旅客数を記録した古典的な時系列データです。上昇トレンドと12ヶ月の季節性が含まれており、時系列モデルのベンチマークとして広く使用されています。

import torch
import torch.nn as nn
import numpy as np
import matplotlib.pyplot as plt

# Airline Passengersデータ(1949-1960, 月次, 単位: 千人)
airline_data = np.array([
    112, 118, 132, 129, 121, 135, 148, 148, 136, 119, 104, 118,
    115, 126, 141, 135, 125, 149, 170, 170, 158, 133, 114, 140,
    145, 150, 178, 163, 172, 178, 199, 199, 184, 162, 146, 166,
    171, 180, 193, 181, 183, 218, 230, 242, 209, 191, 172, 194,
    196, 196, 236, 235, 229, 243, 264, 272, 237, 211, 180, 201,
    204, 188, 235, 227, 234, 264, 302, 293, 259, 229, 203, 229,
    242, 233, 267, 269, 270, 315, 364, 347, 312, 274, 237, 278,
    284, 277, 317, 313, 318, 374, 413, 405, 355, 306, 271, 306,
    315, 301, 356, 348, 355, 422, 465, 467, 404, 347, 305, 336,
    340, 318, 362, 348, 363, 435, 491, 505, 404, 359, 310, 337,
    360, 342, 406, 396, 420, 472, 548, 559, 463, 407, 362, 405,
    417, 391, 419, 461, 472, 535, 622, 606, 508, 461, 390, 432
], dtype=np.float32)

# 正規化(0-1スケーリング)
data_min = airline_data.min()
data_max = airline_data.max()
data_norm = (airline_data - data_min) / (data_max - data_min)

plt.figure(figsize=(12, 4))
plt.plot(airline_data, color='steelblue', linewidth=1.5)
plt.xlabel('Month (from Jan 1949)')
plt.ylabel('Passengers (thousands)')
plt.title('Airline Passengers Dataset (1949-1960)')
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()

このグラフには2つの明確な特徴が見て取れます。第一に、右肩上がりの上昇トレンドがあり、年々旅客数が増加しています。第二に、毎年夏に旅客数がピークを迎える12ヶ月の季節性があり、しかもその振幅が年々大きくなっています(加法的ではなく乗法的な季節性)。Transformerがこれらのパターンを同時に学習できるかが検証のポイントです。

Transformerによる学習と予測

# データの準備
seq_len = 24  # 2年分のデータで予測
train_size = 108  # 最初の9年を訓練データ
test_size = len(data_norm) - train_size  # 残り3年をテスト

X_train, Y_train = create_sequences(data_norm[:train_size], seq_len)
X_test_full, Y_test_full = create_sequences(data_norm, seq_len)

# モデルの初期化と学習
torch.manual_seed(42)
model_airline = TimeSeriesTransformer(
    d_model=32, nhead=4, num_layers=2, d_ff=128, dropout=0.1
)

losses_airline = train_model(
    model_airline, X_train, Y_train,
    n_epochs=200, batch_size=16, lr=0.001
)
# 損失の推移
plt.figure(figsize=(10, 4))
plt.plot(losses_airline, color='steelblue', linewidth=1.5)
plt.xlabel('Epoch')
plt.ylabel('MSE Loss')
plt.title('Transformer Training Loss on Airline Passengers')
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()

Airline Passengersデータは144点しかなく、訓練データはさらに少ないため、合成データと比べて損失の減少が緩やかです。データが少ない場合でもTransformerが合理的な損失に収束できるのは、Self-Attentionが12ヶ月の周期パターンを直接的に捉えられるためと考えられます。

予測結果の可視化

model_airline.eval()
with torch.no_grad():
    all_pred = model_airline(X_test_full)

# 最後の時刻の予測値を抽出
pred_values = all_pred[:, -1, 0].numpy()
actual_values = Y_test_full[:, -1, 0].numpy()

# 逆正規化
pred_original = pred_values * (data_max - data_min) + data_min
actual_original = actual_values * (data_max - data_min) + data_min

# 可視化
plt.figure(figsize=(12, 5))
time_axis = np.arange(seq_len, len(airline_data))
plt.plot(range(len(airline_data)), airline_data,
         label='Actual', color='steelblue', linewidth=2)
plt.plot(time_axis, pred_original,
         label='Transformer Prediction', color='coral',
         linewidth=2, linestyle='--')
plt.axvline(x=train_size, color='gray', linestyle=':', linewidth=1,
            label='Train/Test Split')
plt.xlabel('Month')
plt.ylabel('Passengers (thousands)')
plt.title('Airline Passengers: Transformer Prediction')
plt.legend(fontsize=11)
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()

# テスト期間のMSE
test_start_idx = train_size - seq_len
test_mse = np.mean(
    (pred_original[test_start_idx:] - actual_original[test_start_idx:])**2
)
print(f"Test MSE: {test_mse:.2f}")
print(f"Test RMSE: {np.sqrt(test_mse):.2f} (千人)")

予測結果のグラフでは、灰色の縦線を境に左が訓練期間、右がテスト期間です。Transformerが上昇トレンドと季節性の両方を学習し、テスト期間でも合理的な予測を行っていることが確認できます。特に、12ヶ月周期のピークとボトムのタイミングが正確に捉えられている点が重要です。これは、Self-Attentionが系列長24の窓の中で「12ステップ前の同じ月」への高いAttention重みを学習することで実現されています。

ただし、テスト期間の後半では予測と実際の値のズレが大きくなる傾向があります。これは、訓練データに含まれない規模の旅客数(500人以上)が出現するためであり、外挿の困難さを示しています。

実データでTransformerの予測能力を確認できたので、最後にRNN/LSTMとの性能を定量的に比較しましょう。

RNN/LSTMとの比較

比較実験の設計

公平な比較のために、RNN、LSTM、Transformerの3モデルを同じデータ(Airline Passengers)、同じ系列長、同等のパラメータ数で学習させます。

import torch
import torch.nn as nn
import numpy as np
import matplotlib.pyplot as plt

# SimpleRNNモデル
class SimpleRNN(nn.Module):
    def __init__(self, hidden_dim=64):
        super().__init__()
        self.rnn = nn.RNN(input_size=1, hidden_size=hidden_dim, batch_first=True)
        self.fc = nn.Linear(hidden_dim, 1)

    def forward(self, x):
        out, _ = self.rnn(x)
        return self.fc(out)

# LSTMモデル
class SimpleLSTM(nn.Module):
    def __init__(self, hidden_dim=64):
        super().__init__()
        self.lstm = nn.LSTM(input_size=1, hidden_size=hidden_dim, batch_first=True)
        self.fc = nn.Linear(hidden_dim, 1)

    def forward(self, x):
        out, _ = self.lstm(x)
        return self.fc(out)

# 各モデルのパラメータ数
rnn_model = SimpleRNN(hidden_dim=64)
lstm_model = SimpleLSTM(hidden_dim=32)  # LSTMは4倍のパラメータなので次元を調整
transformer_model = TimeSeriesTransformer(
    d_model=32, nhead=4, num_layers=2, d_ff=128, dropout=0.1
)

for name, m in [('RNN', rnn_model), ('LSTM', lstm_model),
                ('Transformer', transformer_model)]:
    n_params = sum(p.numel() for p in m.parameters())
    print(f"{name}: {n_params:,} parameters")

学習と比較

torch.manual_seed(42)

# 各モデルの学習
models = {
    'RNN': SimpleRNN(hidden_dim=64),
    'LSTM': SimpleLSTM(hidden_dim=32),
    'Transformer': TimeSeriesTransformer(
        d_model=32, nhead=4, num_layers=2, d_ff=128, dropout=0.1
    )
}

all_losses = {}
for name, m in models.items():
    print(f"\n--- Training {name} ---")
    torch.manual_seed(42)
    all_losses[name] = train_model(
        m, X_train, Y_train, n_epochs=200, batch_size=16, lr=0.001
    )

損失の比較

plt.figure(figsize=(10, 5))
colors = {'RNN': 'gray', 'LSTM': 'steelblue', 'Transformer': 'coral'}
for name, loss_list in all_losses.items():
    plt.plot(loss_list, label=name, color=colors[name], linewidth=2)
plt.xlabel('Epoch')
plt.ylabel('MSE Loss')
plt.title('Training Loss Comparison: RNN vs LSTM vs Transformer')
plt.legend(fontsize=12)
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()

損失の比較グラフから、3つのモデルの学習特性の違いが明確に読み取れます。

  1. RNN: 勾配消失の影響で学習が最も遅く、損失が高い水準にとどまります。系列長24に対してもバニラRNNでは十分な長期依存性を学習できないことがわかります
  2. LSTM: ゲート機構により安定した学習が行われ、RNNよりも大幅に低い損失に到達します
  3. Transformer: Self-Attentionにより12ヶ月の周期パターンを直接的に捉えられるため、最終的にはLSTMと同等かそれ以下の損失に到達する傾向があります

予測結果の比較

fig, axes = plt.subplots(3, 1, figsize=(12, 12))
model_names = ['RNN', 'LSTM', 'Transformer']

for ax, name in zip(axes, model_names):
    m = models[name]
    m.eval()
    with torch.no_grad():
        pred = m(X_test_full)

    pred_vals = pred[:, -1, 0].numpy()
    pred_orig = pred_vals * (data_max - data_min) + data_min

    ax.plot(range(len(airline_data)), airline_data,
            label='Actual', color='steelblue', linewidth=2)
    ax.plot(np.arange(seq_len, len(airline_data)), pred_orig,
            label=f'{name} Prediction', color='coral',
            linewidth=2, linestyle='--')
    ax.axvline(x=train_size, color='gray', linestyle=':', linewidth=1)
    ax.set_ylabel('Passengers')
    ax.set_title(f'{name} Prediction')
    ax.legend()
    ax.grid(True, alpha=0.3)

plt.xlabel('Month')
plt.tight_layout()
plt.show()

3つのモデルの予測を並べて見ると、その違いが一目瞭然です。RNNは季節性のパターンを一部捉えていますが、振幅の変動が不安定でノイズが多い予測になっています。LSTMはゲート機構の恩恵で季節パターンを安定的に追跡していますが、テスト期間の後半で予測精度が低下する傾向があります。Transformerは12ヶ月の周期パターンを直接的にAttention重みで捉えているため、季節性の位相とトレンドの両方を比較的正確に再現しています。

定量的な性能比較

print("=" * 50)
print("Test Period Performance Comparison")
print("=" * 50)

for name in model_names:
    m = models[name]
    m.eval()
    with torch.no_grad():
        pred = m(X_test_full)

    pred_vals = pred[:, -1, 0].numpy()
    pred_orig = pred_vals * (data_max - data_min) + data_min
    actual_orig = Y_test_full[:, -1, 0].numpy() * (data_max - data_min) + data_min

    test_idx = train_size - seq_len
    mse = np.mean((pred_orig[test_idx:] - actual_orig[test_idx:])**2)
    rmse = np.sqrt(mse)
    mae = np.mean(np.abs(pred_orig[test_idx:] - actual_orig[test_idx:]))

    print(f"{name:12s}: MSE={mse:8.2f}, RMSE={rmse:6.2f}, MAE={mae:6.2f}")

定量的な結果から、Transformerが最も低いMSEとRMSEを達成していることが確認できます。ただし、この結果はデータセットやハイパーパラメータに依存します。特に、Airline Passengersのようにデータ量が少ないケースでは、Transformerのパラメータ数が多い分、過学習のリスクもあります。データ量が十分にある場面では、Transformerの利点がより顕著になります。

Transformerの時系列予測における利点と課題

利点

ここまでの理論と実験を踏まえて、Transformerの時系列予測における主な利点をまとめます。

1. 直接的な長距離依存性の学習: Self-Attentionにより、任意の2時点間の依存関係を1ステップで捉えられます。12ヶ月前、24ヶ月前のパターンへの直接的なアクセスが可能です。

2. 並列計算による学習速度: 系列内の全時点を同時に処理できるため、GPUの並列計算能力をフル活用できます。特に長い系列では、RNNの逐次処理と比較して大幅な速度向上が得られます。

3. 解釈性: Attention重みを可視化することで、モデルが「どの過去の時点を重要視しているか」を人間が理解できます。この解釈性は、ドメインの専門家との協働やモデルの信頼性向上に有益です。

4. 柔軟なアーキテクチャ: Multi-Head Attentionにより、異なる種類の時間依存性(短期トレンド、長期周期、特定パターン)を複数のヘッドで同時に学習できます。

課題

1. $O(T^2)$ の計算量: Self-Attentionの計算量は系列長の二乗に比例します。数千ステップ以上の長い系列では計算コストが問題になります。この問題に対して、Informer(Zhou et al., 2021)はProbSparse Self-Attentionで $O(T \log T)$ に削減し、Autoformer(Wu et al., 2021)はAuto-Correlationメカニズムで効率化を図っています。

2. データ効率: Transformerは大量のパラメータを持つため、データが少ない場合はRNN系のモデルに劣ることがあります。Airline Passengersのような小規模データセットでは、正則化やデータ拡張が重要になります。

3. 位置エンコーディングの外挿性: 固定のsin/cos位置エンコーディングは、訓練時に見た系列長を大幅に超える位置への外挿が困難な場合があります。RoPE(Rotary Position Embedding)やALiBi(Attention with Linear Biases)などの改良手法がこの問題に取り組んでいます。

4. 非定常性への対応: 時系列データのトレンドや分散が時間とともに変化する非定常性に対して、標準的なTransformerは明示的な対処を行いません。Reversible Instance Normalization(RevIN)などの前処理が有効です。

まとめ

本記事では、Transformerを時系列予測に適用する理論をSelf-Attentionの仕組みから解説し、PyTorchで実装して合成データと実データで実験しました。

  • RNN系の限界: 逐次処理による並列化の制約と、間接的な長距離依存性の伝達
  • Causal Mask: 時系列の因果性を保証するための上三角マスク。未来の情報を遮断
  • 位置エンコーディング: sin/cosベースの多周波数構造が時系列の周期パターンと自然に対応
  • Encoder-onlyアーキテクチャ: GPT型のCausal Self-Attentionで次ステップ予測を実現
  • Multi-Head Attention: 異なるヘッドが短期・長期の異なる時間依存性を分業的に学習
  • 実験結果: 合成正弦波とAirline Passengersの両方で、TransformerがRNN/LSTMと同等以上の性能を発揮。特に12ヶ月の周期パターンの直接的な学習に成功
  • 課題: $O(T^2)$ の計算量、データ効率、外挿性の改善が今後の研究方向

Transformerの時系列予測への応用は急速に発展しており、Informer、Autoformer、PatchTSTなど多くの改良モデルが提案されています。これらの最新モデルを理解するための基盤として、本記事で解説した基本的なTransformer時系列予測アーキテクチャの理解が役立ちます。

次のステップとして、以下の記事も参考にしてください。