Day 123 — Temporal Fusion Transformer入門 — Attention+分位点予測

2026-08-13 紫 / Phase 5 コーディング Attention / Pinball Loss / Quantile Forecast / TFT

📚 背景知識(読んでから問題へ)

🔀
Day121のLSTMとDay122のProphetには、それぞれ弱点がありました。LSTMは高精度な予測ができる一方「なぜその値を予測したのか」を説明しづらいブラックボックスで、Prophetは「トレンド+季節性+イベント」という決まった型に分解できる代わりに複雑な非線形パターンを表現しきれませんでした。今日学ぶTFT(Temporal Fusion Transformer)は、LSTM系にAttention機構を組み合わせて設計された時系列予測モデルです。本来は多くの部品を持つ大きなモデルですが、今日は核となる2つのアイデアだけを取り出した簡易版を実装します。
📌
アイデア1: Attention=「どの過去日に付箋を貼るか」: LSTMは14日分の情報を「1本の要約(隠れ状態)」に圧縮してから予測していた。Attentionは発想が逆で、過去の各日ごとに「今回の予測にどれだけ重要か」という重み(0〜1、合計1に正規化)を計算してから、その重みに応じて足し合わせる。レポートを書くとき全ページを一様に暗記するのではなく、重要なページに太い付箋を貼って、あとで「どのページを一番参考にしたか」を説明できるようにするイメージ。
📏
アイデア2: 分位点予測(Quantile Forecast)=「1点ではなく幅で予測する」: Day121・Day122はどちらも「明日の売上は115だろう」という1つの数値(点予測)を返していた。実務や一部Kaggleコンペ(M5 Uncertainty等)では「P10=100、P50=115、P90=130」のように幅を持たせた予測が求められる。これを実現する損失関数がPinball Loss(分位点損失)で、「予測が低すぎた場合と高すぎた場合でペナルティの重さを非対称に変える」ことで、$q$タイル値を狙って学習させる。
🔑
キーワード
attention weight: 各過去タイムステップに割り振られる0〜1の重要度(合計1)
query / key / value: 「今知りたいこと(query)」と「各過去日の特徴(key)」の類似度でweightを決め、その重みで「各過去日の情報(value)」を混ぜ合わせる
pinball loss(quantile loss): 分位点予測の学習に使う非対称な損失関数
coverage(被覆率): 実際の値が予測区間(P10〜P90)に収まった割合。理想はP10〜P90なら80%

🔍 Attentionの仕組み — 「付箋の太さ」で過去日を混ぜ合わせる

過去21日(古い→新しい)の各日にAttention重みを計算 lag14 lag7 lag1 21日前(古い) 1日前(新しい) → 週次季節性があると lag7・lag14(同じ曜日)の重みが直近以外でも高くなりやすい 重み付き平均で1本の文脈ベクトル → P10 / P50 / P90 の3本を出力 context vector P10 P50 P90

LSTMは1本の隠れ状態にすべてを圧縮してしまうため「どの日が効いたか」を取り出せないが、Attentionは各日の重みを保持したまま混ぜ合わせるため、学習後に重みを取り出して「モデルはlag7・lag14に注目していた」と説明できる。さらに文脈ベクトルから3本の分位点(P10/P50/P90)を同時に出力することで、点予測ではなく幅のある予測が可能になる。

🗂️ データスキーマ

列名説明
dayint0始まりの通し日数。0〜199の200日分
salesfloat売上 = 100 + トレンド(day×0.4) + 週次季節性(振幅15) + ノイズ(σ=5)。Day121のLSTMと同じ生成過程を200日に拡張したもの
変数役割
WINDOW_SIZE21過去21日(3週間)を見て翌日を予測。lag7・lag14が窓の中に完全に収まる長さ
TRAIN_LEN170先頭170日をtrain、残り30日をvalとして評価
QUANTILES(0.1, 0.5, 0.9)予測する3つの分位点(P10・中央値P50・P90)

🎯 問題

架空店舗の日次売上データ200日分(トレンド+週次季節性+ノイズ、Day121と同じ生成過程を200日に拡張したもの)が与えられます。

import numpy as np
import pandas as pd

np.random.seed(42)
days = np.arange(200)
trend = days * 0.4
weekly_seasonality = 15 * np.sin(2 * np.pi * days / 7)
noise = np.random.normal(0, 5, size=200)
sales = 100 + trend + weekly_seasonality + noise

df = pd.DataFrame({"day": days, "sales": sales})

WINDOW_SIZE = 21   # 過去21日(3週間)を見て翌日を予測する
TRAIN_LEN = 170    # 先頭170日をtrain、残り30日をvalとして評価
QUANTILES = (0.1, 0.5, 0.9)
1
コーディング: Day121と同じ手順(trainだけの統計量で標準化・Sliding Window Dataset)を流用しつつ、単一ヘッドのAttentionで文脈ベクトルを作り(P10, P50, P90)を出力するAttentionForecasterを実装する
2
Pinball Loss: 3分位点の合計損失を実装し学習ループを回す。valでPinball Loss・P50 RMSE・coverageを算出する
3
比較・解釈: 「persistence予測+正規分布近似の区間」というナイーブベースラインと比較し、valの数サンプルでAttention重みを取り出してlag1だけでなくlag7・lag14にも重みが置かれているか確認する

💡 ヒント

ヒント1方向性

Attentionは「各過去日を独立に埋め込みベクトルへ変換 → 学習可能な1本のクエリベクトルとの類似度でスコアを計算 → softmaxで重みに正規化 → 重み付き平均で1本の文脈ベクトルにまとめる」という4ステップです。Pinball Lossは(0.1, 0.5, 0.9)それぞれについて個別に誤差を計算し、最後に3つを足し合わせます。

ヒント2アプローチ
  • 標準化・Sliding Window・train/valの時系列分割はDay121と同じ手順(統計量はtrainだけから計算、シャッフルしない)
  • Attentionのスコア計算: scores = (key @ query) / sqrt(d_model) で各タイムステップのスコアを求め、softmax(scores, dim=時間軸)で重みにする
  • 文脈ベクトルは context = (attn.unsqueeze(-1) * value).sum(dim=時間軸) で「重み付き平均」として作る
  • Pinball Lossは各分位点$q$について torch.max(q * error, (q - 1) * error) を計算し、3分位点分を平均する(error = 実際の値 - 予測値
  • ナイーブの正規分布近似区間は、標準正規分布の90%タイル ≈ 1.2816 を使う: p90 = y_prev + 1.2816 * std_diff, p10 = y_prev - 1.2816 * std_diff
ヒント3コード骨格
import torch
import torch.nn as nn

class AttentionForecaster(nn.Module):
    def __init__(self, d_model=16, quantiles=(0.1, 0.5, 0.9)):
        super().__init__()
        self.value_proj = nn.Linear(1, d_model)
        self.query = nn.Parameter(torch.randn(d_model))
        self.key_proj = nn.Linear(d_model, d_model)
        self.val_proj = nn.Linear(d_model, d_model)
        self.head = nn.Linear(d_model, len(quantiles))
        self.d_model = d_model

    def forward(self, x):  # x: (batch, seq_len, 1)
        h = self.value_proj(x)                       # (batch, seq_len, d_model)
        k = self.key_proj(h)
        v = self.val_proj(h)
        scores = (k @ self.query) / (self.d_model ** 0.5)   # (batch, seq_len)
        attn = torch.softmax(scores, dim=1)                  # (batch, seq_len)
        context = (attn.unsqueeze(-1) * v).sum(dim=1)        # (batch, d_model)
        out = self.head(context)                              # (batch, 3)
        return out, attn

def pinball_loss(preds, target, quantiles):
    # preds: (batch, len(quantiles)), target: (batch,)
    losses = []
    for i, q in enumerate(quantiles):
        error = target - preds[:, i]
        losses.append(torch.max(q * error, (q - 1) * error))
    return torch.stack(losses, dim=1).mean()

模範解答

import numpy as np
import pandas as pd
import torch
import torch.nn as nn
from torch.utils.data import Dataset, DataLoader

torch.manual_seed(0)

# --- サンプルデータ生成 ---
np.random.seed(42)
days = np.arange(200)
trend = days * 0.4
weekly_seasonality = 15 * np.sin(2 * np.pi * days / 7)
noise = np.random.normal(0, 5, size=200)
sales = 100 + trend + weekly_seasonality + noise
df = pd.DataFrame({"day": days, "sales": sales})

WINDOW_SIZE = 21
TRAIN_LEN = 170
QUANTILES = (0.1, 0.5, 0.9)

# --- ステップ1: 標準化(trainだけから統計量を計算) ---
train_raw = df["sales"].values[:TRAIN_LEN]
mean, std = train_raw.mean(), train_raw.std()
sales_scaled = (df["sales"].values - mean) / std

# --- ステップ2: Sliding Window Dataset(Day121と同じ構造) ---
class SlidingWindowDataset(Dataset):
    def __init__(self, series, window_size):
        self.series = series
        self.window_size = window_size

    def __len__(self):
        return len(self.series) - self.window_size

    def __getitem__(self, idx):
        x = self.series[idx: idx + self.window_size]
        y = self.series[idx + self.window_size]
        x = torch.tensor(x, dtype=torch.float32).unsqueeze(-1)
        y = torch.tensor(y, dtype=torch.float32)
        return x, y

train_series = sales_scaled[:TRAIN_LEN]
val_series = sales_scaled[TRAIN_LEN - WINDOW_SIZE:]

train_dataset = SlidingWindowDataset(train_series, WINDOW_SIZE)
val_dataset = SlidingWindowDataset(val_series, WINDOW_SIZE)

train_loader = DataLoader(train_dataset, batch_size=16, shuffle=True)
val_loader = DataLoader(val_dataset, batch_size=16, shuffle=False)

# --- ステップ3: Attention + 分位点出力モデル ---
class AttentionForecaster(nn.Module):
    def __init__(self, d_model=16, quantiles=QUANTILES):
        super().__init__()
        self.value_proj = nn.Linear(1, d_model)
        self.query = nn.Parameter(torch.randn(d_model))
        self.key_proj = nn.Linear(d_model, d_model)
        self.val_proj = nn.Linear(d_model, d_model)
        self.head = nn.Linear(d_model, len(quantiles))
        self.d_model = d_model

    def forward(self, x):
        h = self.value_proj(x)
        k = self.key_proj(h)
        v = self.val_proj(h)
        scores = (k @ self.query) / (self.d_model ** 0.5)
        attn = torch.softmax(scores, dim=1)
        context = (attn.unsqueeze(-1) * v).sum(dim=1)
        out = self.head(context)
        return out, attn

def pinball_loss(preds, target, quantiles=QUANTILES):
    losses = []
    for i, q in enumerate(quantiles):
        error = target - preds[:, i]
        losses.append(torch.max(q * error, (q - 1) * error))
    return torch.stack(losses, dim=1).mean()

model = AttentionForecaster(d_model=16)
optimizer = torch.optim.Adam(model.parameters(), lr=0.01)

# --- ステップ4: 学習ループ ---
for epoch in range(80):
    model.train()
    for x, y in train_loader:
        optimizer.zero_grad()
        preds, _ = model(x)
        loss = pinball_loss(preds, y)
        loss.backward()
        optimizer.step()

# --- ステップ5: val評価 ---
model.eval()
all_preds, all_true, all_attn = [], [], []
with torch.no_grad():
    for x, y in val_loader:
        preds, attn = model(x)
        all_preds.append(preds.numpy())
        all_true.append(y.numpy())
        all_attn.append(attn.numpy())
all_preds = np.concatenate(all_preds, axis=0) * std + mean
all_true = np.concatenate(all_true, axis=0) * std + mean
all_attn = np.concatenate(all_attn, axis=0)

p10, p50, p90 = all_preds[:, 0], all_preds[:, 1], all_preds[:, 2]
tft_rmse = np.sqrt(np.mean((p50 - all_true) ** 2))
tft_coverage = np.mean((all_true >= p10) & (all_true <= p90))

preds_t = torch.tensor(all_preds, dtype=torch.float32)
true_t = torch.tensor(all_true, dtype=torch.float32)
tft_pinball = pinball_loss(preds_t, true_t).item()

# --- ステップ6: ナイーブ(persistence + 正規分布近似区間)ベースライン ---
val_raw = df["sales"].values[TRAIN_LEN:]
prev_raw = df["sales"].values[TRAIN_LEN - 1: -1][-len(val_raw):]

train_diffs = np.diff(train_raw)
std_diff = train_diffs.std()
z90 = 1.2816  # 標準正規分布の90%タイル

naive_p50 = prev_raw
naive_p10 = prev_raw - z90 * std_diff
naive_p90 = prev_raw + z90 * std_diff

naive_rmse = np.sqrt(np.mean((naive_p50 - val_raw) ** 2))
naive_coverage = np.mean((val_raw >= naive_p10) & (val_raw <= naive_p90))

naive_preds_t = torch.tensor(np.stack([naive_p10, naive_p50, naive_p90], axis=1), dtype=torch.float32)
naive_true_t = torch.tensor(val_raw, dtype=torch.float32)
naive_pinball = pinball_loss(naive_preds_t, naive_true_t).item()

print(f"{'':10s} {'RMSE':>8s} {'Pinball':>10s} {'Coverage':>10s}")
print(f"{'TFT風':10s} {tft_rmse:8.3f} {tft_pinball:10.3f} {tft_coverage:10.1%}")
print(f"{'Naive':10s} {naive_rmse:8.3f} {naive_pinball:10.3f} {naive_coverage:10.1%}")
# 出力例:
#            RMSE    Pinball   Coverage
# TFT風      5.9xx     1.6xx      83.3%
# Naive      9.8xx     3.1xx      73.3%
# → 点予測(RMSE)・分位点予測(Pinball)ともにAttentionモデルが上回りやすく、
#   coverageも理想の80%に近い値になりやすい。

# --- ステップ7: Attention重みの可視化(週次パターンを見ているか確認) ---
for i in range(3):
    weights = all_attn[i]  # (WINDOW_SIZE,) 古い日→新しい日の順
    lag1_w = weights[-1]
    lag7_w = weights[-7]
    lag14_w = weights[-14]
    print(f"sample{i}: lag1={lag1_w:.3f}  lag7={lag7_w:.3f}  lag14={lag14_w:.3f}  argmax_lag={WINDOW_SIZE - np.argmax(weights)}")
# 出力例(学習が週次パターンを捉えられていれば):
# sample0: lag1=0.08  lag7=0.21  lag14=0.15  argmax_lag=7
# → 直近1日だけでなく、7日前・14日前(=同じ曜日)に高い重みが
#   置かれていれば、モデルが週次季節性を「どこを見て」学習したかを
#   人に説明できる。LSTMではこの内訳を取り出すことはできなかった。
🔍
タスク1相当(理論の確認): このデータには「トレンド+週次季節性+ノイズ」しか含まれていないため、Attentionが本当に週次パターンを見つけられればlag7・lag14の重みが目立って高くなるはず。もし全ての重みがほぼ均等(≈1/21)なら、モデルが週次パターンをまだ学習しきれていないサインなので、epoch数を増やすかlrを調整して再学習するとよい。

📊 評価指標の可視化(val 30日、RMSE・Pinballは小さいほど、Coverageは80%に近いほど良い)

RMSE — Naive
9.8(例)
P50点予測の誤差
RMSE — TFT風
5.9(例)
Attentionで文脈を反映
Pinball Loss — Naive
3.1(例)
正規分布近似の区間
Pinball Loss — TFT風
1.6(例)
学習で分位点を最適化
Coverage — Naive
73.3%(例)
理想80%よりやや狭い
Coverage — TFT風
83.3%(例)
理想80%に近い
🏁
点予測(RMSE)・分位点予測(Pinball Loss)ともにAttentionモデルがナイーブを上回りやすく、coverageも理想の80%に近い値になりやすい。ただし実際の数値は乱数シード・学習率・epoch数で変動するため、必ず自分の環境で再現して確認すること。coverageが極端に0%や100%に近い場合は、区間が狭すぎる/広すぎるサインなので分位点の学習がうまくいっていない可能性がある。

🪜 Step-by-Step 解説

1queryは「今知りたいこと」を表す学習可能な1本のベクトル

self.query = nn.Parameter(torch.randn(d_model))
🎯
なぜこうするか: 本来のTransformerではqueryも入力ごとに動的に計算されるが、今回は「翌日の値を予測する」という目的が固定されているため、目的そのものを表す1本のベクトルを学習パラメータとして持たせるだけで十分に機能する。これにより「過去21日それぞれが、この固定された予測目的にどれだけ関係が深いか」というスコアを計算できる。

2sqrt(d_model)で割るのはスケール調整のため

scores = (k @ self.query) / (self.d_model ** 0.5)
📐
なぜこうするか: d_modelが大きいほど内積のスコアの分散も大きくなりやすい。分散が大きすぎるとsoftmax後の重みが「1箇所だけ1.0、他は0」のように極端になりすぎ、学習が不安定になる。sqrt(d_model)で割ることでスコアの大きさを一定範囲に抑え、Attentionの重みがなだらかに分布しやすくする(Transformer系で広く使われる標準的なテクニック)。

3Pinball Lossは「予測が低すぎ/高すぎ」で罰則の重さを変える

losses.append(torch.max(q * error, (q - 1) * error))
⚖️
なぜこうするか: 通常のMSE(Day121で使用)は「予測が高すぎても低すぎても同じだけ罰する」対称な損失。しかし$q=0.9$の予測は「実際の値の90%はこれを下回るはず」という非対称な意味を持つべきなので、error = 実際 - 予測が正(予測が低すぎた)のときは重い罰則(係数$q=0.9$)、負(予測が高すぎた)のときは軽い罰則(係数$|q-1|=0.1$)を与える必要がある。この非対称性がP10・P50・P90という異なる性質の3本の予測を1つのモデルから同時に出力可能にしている。

4coverageで「区間の幅が適切か」を検証する

tft_coverage = np.mean((all_true >= p10) & (all_true <= p90))
📦
なぜこうするか: RMSEはP50(中央値)だけを評価する指標であり、P10・P90が意味のある区間になっているかは別途確認が必要。P10〜P90の区間は理論上「80%の確率で実際の値を含む」はずなので、coverageが80%から大きくずれている(例: 40%や99%)場合は区間が狭すぎる/広すぎることを意味し、分位点予測として信頼できないことになる。

🧮 数学・統計の補足(文系向け)

Pinball Lossを「天気予報の傘の例え」で理解する: 「明日の降水確率90%」と言われたら「10回中9回は雨が降る」という意味だと期待する。もし実際には10回中3回しか降らなかったら、その天気予報は「甘すぎる(自信過剰)」と言える。Pinball Lossの$q=0.9$はまさにこの「10回中9回は実際の値がこの予測を下回るような、やや高めの値」を学習させるための損失。予測が外れて「実際の方が高かった」場合は重く罰し、「実際の方が低かった」場合は軽く罰することで、モデルに「やや高めに出しておく」動機を与える。
📐
もし数式を見たら:
Attention(q, K, V) = Σ softmax(q·k_i / √d)_i · v_i

→ これは「クエリqと各キーk_iの類似度をsoftmaxで重みに変換し、その重みで各バリューv_iを混ぜ合わせる」という意味。「似ている過去日ほど、その日の情報を強く混ぜる」という直感がそのまま数式になったもの、と捉えれば十分。

🏆 Kaggleでの実践的な使い方 / 時系列ブロック(Day121-123)のまとめ

よく使われるコンペカテゴリ: ☐ 表形式データ(Tabular) / ☐ 自然言語処理(NLP) / ☐ 画像認識(CV) / ☑ 時系列(Time Series)

評価指標そのもの

🛒 M5 Forecasting - Uncertainty

Pinball Lossが本番の評価指標

Kaggleで実際に「点予測ではなく分位点予測(P0.005〜P0.995の9本)」を競わせたコンペ。評価指標はPinball Lossの平均(Weighted Scaled Pinball Loss)で、今日実装したPinball Lossがそのまま本番の評価指標になる。

解釈性の道具

🔬 上位解法での位置づけ

分位点回帰・解釈性の補完役

完全なTFTライブラリがそのまま主力になることは多くないが、「Attentionで解釈性を保ちつつ深層学習の表現力を使う」「複数の分位点を1モデルで同時に出す」発想は、GBDTの分位点回帰(objective="quantile")やアンサンブルの一部として広く使われる。

使い分けの目安

⚖️ 目的で選ぶ3つの武器

精度/解釈性/不確実性

「精度重視」→GBDT+LSTM。「なぜその予測かを説明したい」→Prophet or Attention重み。「予測の幅そのものが評価対象」→分位点予測(TFT風・GBDTのquantile回帰)。実務は「GBDTでベースライン→必要に応じて補う」が定石。

時系列ブロック(Day121-123)3手法の比較

観点LSTM(Day121)Prophet(Day122)TFT風Attention(Day123)
予測の型点予測(1つの数値)点予測(+簡易な信頼区間)分位点予測(P10/P50/P90)
解釈性低い(隠れ状態はブラックボックス)高い(トレンド・季節性・holidaysに分解)中〜高い(Attention重みで「どの日を見たか」が分かる)
非線形パターンへの強さ高い(データ量があれば)低い(加法モデルの枠を超えられない)高い(Attention+NNの表現力)
少データでの安定性低い(過学習しやすい)高い(成分の仮定が効く)低い(LSTMと同様にデータ量が必要)
Kaggleでの主な使いどころGBDTの補完・アンサンブル要員EDA・ベースライン・解釈用の可視化不確実性が評価対象のコンペ(M5 Uncertainty等)

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
Attention重みの高いタイムステップ=「その日の売上の値が大きい」と誤解する「重要=値が大きい」という直感で捉えてしまうAttention重みは「予測にどれだけ寄与したか」であり、値の大小とは無関係。売上が低い日でも「週の谷を示す重要な手がかり」として高い重みを持つことがある
P10・P50・P90を別々の3つの独立したモデルだと思ってしまうLightGBMのquantile回帰では分位点ごとに別モデルを立てることが多いため今日の実装は1つのモデルが3つの出力ヘッドを同時に持ち、共通の文脈ベクトルから3本の値を一度に出している。TFTの設計思想も「1モデルで複数分位点を同時に扱う」効率性にある
coverageが80%ちょうどでないと「モデルが失敗している」と判断してしまう理論値との完全一致を期待してしまうval30サンプル程度の少数評価では、理想値(80%)から±10%程度ずれるのは統計的なばらつきの範囲内。傾向として大きく乖離していないかを見る指標として使う
Pinball Lossの値だけを見て「小さいから良いモデル」と判断してしまう単一指標で完結させたい心理が働くPinball Lossが小さくてもP10>P90のように大小関係が崩れていたり、coverageが極端に偏っていたりすると実用上意味がない予測になる。RMSE・Pinball・coverageの3つを合わせて確認する習慣が必要

🚀 次のステップ

  • 発展: d_modelを増やす、Attentionのヘッド数を複数(マルチヘッド)にする、window_sizeを7・14・21・28で比較するなど、今日の簡易実装を本物のマルチヘッドAttentionに近づけてみましょう。またpytorch-forecastingライブラリのTemporalFusionTransformerクラスを実際に動かし、今日理解した「Attention」「分位点予測」がライブラリの中でどう実装されているかを覗いてみるのもおすすめです
  • 次回予告: Day 124 — マルチモーダル学習入門。Day121(LSTM)・Day122(Prophet)・Day123(TFT風Attention)で時系列ブロックが完結しました。次回からはPhase 5の締めくくりとして、画像・テキストなど複数の種類のデータを1つのモデルで扱うマルチモーダル学習の基礎に入ります

Phase 5 の学習マップ(全20テーマ予定)

1-5 DL基礎(PyTorch) 完了
6-10 NLP(後日)
11-15 CV 完了
16-18 時系列 完了(③TFT)
19-20 マルチモーダル ← 次回

Day121でLSTM(時間を直接扱うニューラルネット)、Day122でProphet(成分に分解して予測)を学び、今日Day123のTFT風Attention(重要度の可視化+分位点予測)で時系列ブロック(16-18)が完了した。次回からPhase 5最後のブロック「マルチモーダル」に入る。

📝 自己評価(解いた後に記入)

自分の回答・気づき・メモ: