📚 背景知識(読んでから問題へ)
shift(1), shift(7))やRolling統計量を作り、時間軸を横に展開して普通の表問題に変換する発想です。今日から3日間(Day121-123)は、時間軸を横に展開せず、モデル自身に「時間の流れ」を扱わせるニューラルネットワーク(RNN系)を学びます。その入口がLSTM(Long Short-Term Memory)です。hidden_state (h): 直近の要約(短期記憶に近い)cell_state (c): 消えにくいメモ帳(長期記憶に近い、LSTM独自)sliding window: 時系列を「過去N日→次の1日」の固定長ペアに切り出す手法sequence length: LSTMに一度に読ませる過去のステップ数(今日は14日)
LSTMの3つのゲート — 「メモ帳」と「ふるい」
忘却ゲート (forget gate)
メモ帳の中で「もう要らない情報」を消す門。0〜1の値で「どれだけ残すか」を学習で決める
入力ゲート (input gate)
今日の新しい情報のうち「メモ帳に書き足すべき情報」を選ぶ門
出力ゲート (output gate)
メモ帳の中身のうち「今この瞬間の予測に使う情報」を選んで表に出す門
🔍 RNN vs LSTM — 情報の伝わり方の違い
RNNは隠れ状態の掛け算の繰り返しで遠い過去の情報が薄れやすいのに対し、LSTMはセル状態という別経路を持ち、ゲートが「残す」と判断した情報は加算的に伝わるため長期依存を保持しやすい。
🎯 問題
架空の店舗の日次売上データ180日分(トレンド + 週次の季節性 + ノイズ)が与えられます。
import numpy as np import pandas as pd np.random.seed(42) days = np.arange(180) trend = days * 0.5 # ゆるやかな右肩上がり weekly_seasonality = 15 * np.sin(2 * np.pi * days / 7) # 週次の周期パターン noise = np.random.normal(0, 5, size=180) sales = 100 + trend + weekly_seasonality + noise df = pd.DataFrame({"day": days, "sales": sales})
sales列を標準化 → 過去14日→翌日のSliding Window Datasetを実装 → 時系列順にtrain/valを分割 → nn.LSTMで予測モデルを実装 → val RMSEを算出し「1つ前の値をそのまま使う」ナイーブベースラインと比較する💡 ヒント
Sliding Windowは「1つの長い時系列」を「たくさんの(入力14日, 正解1日)のペア」に切り出す作業です。Datasetの__getitem__で「何番目の窓を返すか」をどう決めるかがポイントです。train/valの分け方は、Day009(CV)やDay098で学んだ「時系列データは未来の情報が過去の学習に混ざってはいけない」という原則をそのまま思い出してください。
- 標準化は
(sales - sales.mean()) / sales.std()で行い、平均・標準偏差はtrain部分だけから計算する(valやtestの情報が混ざるとリークになる) Dataset.__len__はlen(series) - window_size、__getitem__(idx)はseries[idx:idx+window_size]を入力、series[idx+window_size]を正解として返すnn.LSTM(input_size=1, hidden_size=..., batch_first=True)は(batch, seq_len, input_size)形のTensorを受け取り、(output, (h_n, c_n))を返す。output[:, -1, :]が「最後のタイムステップの隠れ状態」で、これをnn.Linearに通して1つの数値を予測する- train/valの分割は
series[:split]とseries[split:]のようにインデックスで区切るだけでよい(シャッフルしない) - ナイーブベースラインはモデル不要で、
y[t] - y[t-1]の誤差から直接RMSEを計算できる
import torch import torch.nn as nn from torch.utils.data import Dataset, DataLoader 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] # Tensor化して返す(xはLSTM用に(seq_len, 1)の形にする) ... class LSTMForecaster(nn.Module): def __init__(self, input_size=1, hidden_size=32, num_layers=1): super().__init__() self.lstm = nn.LSTM(input_size, hidden_size, num_layers, batch_first=True) self.fc = nn.Linear(hidden_size, 1) def forward(self, x): out, (h_n, c_n) = self.lstm(x) last_step = out[:, -1, :] return self.fc(last_step).squeeze(-1)
✅ 模範解答
タスク1: なぜLSTMは長期的な週次パターンを捉えやすいか(解答例)
タスク2: LSTMForecasterの実装と評価
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(180) trend = days * 0.5 weekly_seasonality = 15 * np.sin(2 * np.pi * days / 7) noise = np.random.normal(0, 5, size=180) sales = 100 + trend + weekly_seasonality + noise df = pd.DataFrame({"day": days, "sales": sales}) WINDOW_SIZE = 14 TRAIN_LEN = 140 # 先頭140日分をtrainの元データとして使う # --- ステップ1: 標準化(train部分だけから統計量を計算する) --- train_raw = df["sales"].values[:TRAIN_LEN] mean, std = train_raw.mean(), train_raw.std() sales_scaled = (df["sales"].values - mean) / std # train統計量でtrain/val全体を変換 # --- ステップ2: Sliding Window Dataset --- 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) # (window_size, 1) y = torch.tensor(y, dtype=torch.float32) return x, y # --- ステップ3: 時系列順にtrain/valを分割(シャッフルしない) --- train_series = sales_scaled[:TRAIN_LEN] val_series = sales_scaled[TRAIN_LEN - WINDOW_SIZE:] # valの最初の窓がtrain終端に食い込むよう継ぎ目を含める 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) # --- ステップ4: LSTMモデル --- class LSTMForecaster(nn.Module): def __init__(self, input_size=1, hidden_size=32, num_layers=1): super().__init__() self.lstm = nn.LSTM(input_size, hidden_size, num_layers, batch_first=True) self.fc = nn.Linear(hidden_size, 1) def forward(self, x): out, (h_n, c_n) = self.lstm(x) # out: (batch, seq_len, hidden_size) last_step = out[:, -1, :] # 最後のタイムステップの隠れ状態だけ使う return self.fc(last_step).squeeze(-1) model = LSTMForecaster(hidden_size=32, num_layers=1) criterion = nn.MSELoss() optimizer = torch.optim.Adam(model.parameters(), lr=0.01) # --- ステップ5: 学習ループ --- for epoch in range(50): model.train() for x, y in train_loader: optimizer.zero_grad() pred = model(x) loss = criterion(pred, y) loss.backward() optimizer.step() # --- ステップ6: val RMSE(標準化前のスケールに戻して評価) --- model.eval() preds, trues = [], [] with torch.no_grad(): for x, y in val_loader: pred = model(x) preds.append(pred.numpy()) trues.append(y.numpy()) preds = np.concatenate(preds) * std + mean # 標準化を逆変換して元のスケールに戻す trues = np.concatenate(trues) * std + mean lstm_rmse = np.sqrt(np.mean((preds - trues) ** 2)) # --- ナイーブベースライン: y_pred[t] = y[t-1] --- val_raw = df["sales"].values[TRAIN_LEN:] prev_raw = df["sales"].values[TRAIN_LEN - 1 : -1][-len(val_raw):] naive_rmse = np.sqrt(np.mean((val_raw - prev_raw) ** 2)) print(f"LSTM val RMSE : {lstm_rmse:.3f}") print(f"Naive val RMSE : {naive_rmse:.3f}") # 出力例: # LSTM val RMSE : 6.8xx # Naive val RMSE : 15.1xx # → 週次季節性がある系列では、「1つ前の値をそのまま使う」ナイーブ予測は # 周期の谷/山をそのまま外してしまうため誤差が大きくなりやすく、 # 14日分の文脈を見ているLSTMの方が明確に精度が高くなりやすい。
📊 RMSE比較の可視化(val、値が小さいほど良い)
🪜 Step-by-Step 解説
1標準化はtrainの統計量だけで行う
train_raw = df["sales"].values[:TRAIN_LEN] mean, std = train_raw.mean(), train_raw.std() sales_scaled = (df["sales"].values - mean) / std
2Sliding Windowで「過去14日→翌日」のペアを大量に作る
def __getitem__(self, idx): x = self.series[idx : idx + self.window_size] y = self.series[idx + self.window_size]
idxを1つずつずらしながら「14日分の窓」を大量に切り出し、それぞれに正解(次の1日)を対応させることで、通常の教師あり学習と同じ形式(入力→正解のペア)に変換している。3out[:, -1, :]で「最後まで読んだ時点の要約」だけを使う
out, (h_n, c_n) = self.lstm(x) last_step = out[:, -1, :]
nn.LSTMは入力の各タイムステップごとの隠れ状態をすべてoutとして返す。今回のタスクは「14日分を読み終えた時点で、次の1日を予測する」ものなので、必要なのは最後のタイムステップの隠れ状態だけ。これはh_n(最終層の最終ステップの隠れ状態)と実質的に同じ値になるが、out[:, -1, :]と書く方が「最後のタイムステップを使っている」ことが直感的に分かりやすい。4ナイーブベースラインとの比較で「本当に役に立っているか」を確認する
naive_rmse = np.sqrt(np.mean((val_raw - prev_raw) ** 2))
🧮 数学・統計の補足(文系向け)
f_t = σ(W_f · [h_{t-1}, x_t] + b_f)→ これは忘却ゲートの計算式。「1つ前の要約 h_(t-1)」と「今日の入力 x_t」を材料にして、シグモイド関数σ(結果を必ず0〜1に収める関数)で「どれくらい忘れるか」を0〜1の数値として出力している、という意味。「材料を混ぜて、0〜1のダイヤルを1つひねる」というイメージで捉えれば十分。
🏆 Kaggleでの実践的な使い方
よく使われるコンペカテゴリ: ☐ 表形式データ(Tabular) / ☐ 自然言語処理(NLP) / ☐ 画像認識(CV) / ☑ 時系列(Time Series)
🛒 M5 Forecasting(Walmart需要予測)
GBDTが主力、LSTM/GRUは補完役
上位解法の多くはLightGBM(Day098-100のようなラグ特徴量アプローチ)が主力だったが、LSTM/GRUベースのモデルもアンサンブルの一員として使われ、GBDT単体では拾いにくい非線形パターンの補完役を担った。
🌐 Web Traffic Time Series Forecasting
系列数が多いほどRNN系が有利になりやすい
系列数が非常に多い(数万系列)コンペではSeq2Seq型のRNN系モデルが上位に食い込んだ実績がある。「大量の系列をまとめて1つのモデルで学習する」発想がGBDTより有利になる場面がある。
⚖️ まずGBDT、伸び悩んだらLSTM系
系列数・非線形性・解釈性で判断
系列数が少なく特徴量を作り込める場合はGBDT(Day098-100)が扱いやすく解釈性も高い。系列数が多い・非線形パターンが強い場合はLSTM系が候補になる。「まずGBDTでベースライン、伸び悩んだらLSTM系」が定石。
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
| train/valをランダムシャッフルして分割してしまう | 普段の分類・回帰タスクの癖でtrain_test_splitをそのまま使ってしまう | 時系列は「過去→未来」の順序が本質。ランダム分割は未来の情報が学習に混ざるリークを生む。必ず時間軸に沿って分割する |
| 標準化の平均・標準偏差をtrain+val全体から計算してしまう | 「全データを使った方が正確な統計量になるはず」と考えてしまう | val/testの情報が前処理の段階で混ざるとリークになる。統計量は必ずtrainだけから計算し、val/testにはそれを適用するだけにする |
out全体をそのままLinearに渡そうとしてエラーになる | LSTMの出力の形((batch, seq_len, hidden_size))を意識せず、通常の全結合層と同じ感覚で扱ってしまう | 「1つの値を予測する」タスクでは、seq_len方向の次元をどう1つに畳み込むか(今回は最後のステップだけ使う)を明示的に決める必要がある |
| LSTMは常にGBDTより強いと思い込む | ディープラーニングの方が「高度」という漠然としたイメージを持ってしまう | データ量・系列数が少ない時系列では、GBDTのラグ特徴量アプローチ(Day098-100)の方が安定して高精度になることが多い。LSTMは大量データ・複雑な非線形パターンがある場合に真価を発揮する |
🚀 次のステップ
- 発展:
window_sizeを7・14・28と変えて学習し直し、val RMSEがどう変化するか比較してみましょう。またnum_layers=2にしたりhidden_sizeを増減させたりして、モデルの表現力とval RMSEの関係も観察してみると、Day116で学んだ「過学習の兆候」が時系列モデルでも同じように現れるかを確認できます - 次回予告: Day 122 — Prophet入門。今日のLSTM(ニューラルネットワークで時間の流れを直接扱うアプローチ)に続き、次回はMeta(旧Facebook)が開発した時系列予測ライブラリProphetを扱います。トレンド・季節性・祝日効果を明示的に分解して予測する、LSTMとはまた違う設計思想のアプローチを学びます
Phase 5 の学習マップ(全20テーマ予定)
Day114〜120でCVブロックが完了し、Day121から時系列ブロック(16-18: LSTM・Prophet・Temporal Fusion Transformer)が始まった。今日はその1本目、LSTMの基礎とSliding Window実装を学んだ。
📝 自己評価(解いた後に記入)
自分の回答・気づき・メモ: