📚 背景知識(読んでから問題へ)
attention weight: 各過去タイムステップに割り振られる0〜1の重要度(合計1)query / key / value: 「今知りたいこと(query)」と「各過去日の特徴(key)」の類似度でweightを決め、その重みで「各過去日の情報(value)」を混ぜ合わせるpinball loss(quantile loss): 分位点予測の学習に使う非対称な損失関数coverage(被覆率): 実際の値が予測区間(P10〜P90)に収まった割合。理想はP10〜P90なら80%
🔍 Attentionの仕組み — 「付箋の太さ」で過去日を混ぜ合わせる
LSTMは1本の隠れ状態にすべてを圧縮してしまうため「どの日が効いたか」を取り出せないが、Attentionは各日の重みを保持したまま混ぜ合わせるため、学習後に重みを取り出して「モデルはlag7・lag14に注目していた」と説明できる。さらに文脈ベクトルから3本の分位点(P10/P50/P90)を同時に出力することで、点予測ではなく幅のある予測が可能になる。
🗂️ データスキーマ
| 列名 | 型 | 説明 |
|---|---|---|
| day | int | 0始まりの通し日数。0〜199の200日分 |
| sales | float | 売上 = 100 + トレンド(day×0.4) + 週次季節性(振幅15) + ノイズ(σ=5)。Day121のLSTMと同じ生成過程を200日に拡張したもの |
| 変数 | 値 | 役割 |
|---|---|---|
| WINDOW_SIZE | 21 | 過去21日(3週間)を見て翌日を予測。lag7・lag14が窓の中に完全に収まる長さ |
| TRAIN_LEN | 170 | 先頭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)
(P10, P50, P90)を出力するAttentionForecasterを実装する💡 ヒント
Attentionは「各過去日を独立に埋め込みベクトルへ変換 → 学習可能な1本のクエリベクトルとの類似度でスコアを計算 → softmaxで重みに正規化 → 重み付き平均で1本の文脈ベクトルにまとめる」という4ステップです。Pinball Lossは(0.1, 0.5, 0.9)それぞれについて個別に誤差を計算し、最後に3つを足し合わせます。
- 標準化・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
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ではこの内訳を取り出すことはできなかった。
📊 評価指標の可視化(val 30日、RMSE・Pinballは小さいほど、Coverageは80%に近いほど良い)
🪜 Step-by-Step 解説
1queryは「今知りたいこと」を表す学習可能な1本のベクトル
self.query = nn.Parameter(torch.randn(d_model))
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))
error = 実際 - 予測が正(予測が低すぎた)のときは重い罰則(係数$q=0.9$)、負(予測が高すぎた)のときは軽い罰則(係数$|q-1|=0.1$)を与える必要がある。この非対称性がP10・P50・P90という異なる性質の3本の予測を1つのモデルから同時に出力可能にしている。4coverageで「区間の幅が適切か」を検証する
tft_coverage = np.mean((all_true >= p10) & (all_true <= p90))
🧮 数学・統計の補足(文系向け)
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テーマ予定)
Day121でLSTM(時間を直接扱うニューラルネット)、Day122でProphet(成分に分解して予測)を学び、今日Day123のTFT風Attention(重要度の可視化+分位点予測)で時系列ブロック(16-18)が完了した。次回からPhase 5最後のブロック「マルチモーダル」に入る。
📝 自己評価(解いた後に記入)
自分の回答・気づき・メモ: