Day 121 — 時系列ディープラーニング入門 — LSTM

2026-08-11 紫 / Phase 5 理論→コーディング LSTM / RNN / nn.LSTM / Sliding Window

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

🔀
Day098〜100で学んだのは「GBDTに時間の情報を食わせる」アプローチでした。ラグ特徴量(shift(1), shift(7))やRolling統計量を作り、時間軸を横に展開して普通の表問題に変換する発想です。今日から3日間(Day121-123)は、時間軸を横に展開せず、モデル自身に「時間の流れ」を扱わせるニューラルネットワーク(RNN系)を学びます。その入口がLSTM(Long Short-Term Memory)です。
📮
RNNとは「バケツリレー」方式: RNNは時系列データを1ステップずつ順番に読みながら、「これまで読んだ内容の要約(隠れ状態)」を次のステップに渡していきます。伝言ゲームに近いイメージで、1日目を読んで要約を作り、その要約と2日目のデータを合わせてまた要約を作り…と繰り返し、最後にできた要約から「明日の値」を予測します。
📉
RNNの限界(勾配消失問題): 伝言ゲームを100人でやると最初の人の言葉はほとんど伝わりません。RNNの学習も同様に、誤差を過去へ遡って伝える際、同じ重み行列を何度も掛け合わせるため遠い過去ほど影響力が薄れます。結果として単純なRNNは少し前の情報しか実質的に学習できず、週次の季節性のような「少し先の繰り返し」を捉えるのが苦手です。
🔑
キーワード
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 — 隠れ状態だけが伝わる(掛け算の繰り返しで薄れる) h1 h2 h3 h4 h5 → 矢印がどんどん薄く・細くなる = h1の情報がh5にはほぼ届かない(勾配消失) LSTM — セル状態という「直通の高速道路」が別にある cell state c(消えにくいメモ帳)が一直線に伸びる h1 h2 h3 h4 h5 → 忘却ゲートが「残す」と判断すれば、遠いステップにもほぼそのまま情報が伝わる

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})
1
理論: なぜLSTMがRNNより長期的な週次パターン(7日周期)を捉えやすいのか、「RNNの限界」「LSTMの3つのゲート」の説明を踏まえ自分の言葉で3〜4文にまとめる
2
コーディング(メイン): sales列を標準化 → 過去14日→翌日のSliding Window Datasetを実装 → 時系列順にtrain/valを分割 → nn.LSTMで予測モデルを実装 → val RMSEを算出し「1つ前の値をそのまま使う」ナイーブベースラインと比較する

💡 ヒント

ヒント1方向性

Sliding Windowは「1つの長い時系列」を「たくさんの(入力14日, 正解1日)のペア」に切り出す作業です。Dataset__getitem__で「何番目の窓を返すか」をどう決めるかがポイントです。train/valの分け方は、Day009(CV)やDay098で学んだ「時系列データは未来の情報が過去の学習に混ざってはいけない」という原則をそのまま思い出してください。

ヒント2アプローチ
  • 標準化は(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を計算できる
ヒント3コード骨格
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は長期的な週次パターンを捉えやすいか(解答例)

💬
単純なRNNは、誤差を過去に遡って伝える際に同じ重み行列を何度も掛け合わせるため、7日前・14日前のような少し離れたステップの情報の影響がどんどん薄れてしまい、結果として週次の周期パターンのような「少し先の繰り返し」を学習しにくい。LSTMはこれに対し、隠れ状態とは別に「セル状態」という情報の通り道を持ち、忘却ゲート・入力ゲート・出力ゲートという3つの門で「何を残し・何を消し・何を出力するか」を学習で調整できる。特に忘却ゲートが「今は消さなくてよい」と判断すれば、セル状態を通じて情報がステップを跨いでそのまま伝わりやすくなるため、7日周期のような規則的なパターンの「7日前の情報」を保持しやすくなる。

タスク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の方が明確に精度が高くなりやすい。
⚠️
train/valをシャッフルしない理由: 時系列予測は「過去から未来を当てる」タスクであり、ランダムに分割すると訓練データの中に評価対象より未来の日のデータが混ざり込む可能性がある。モデルが「未来の情報が使える」状態で学習してしまうと、実運用ではあり得ないほど良いスコアが出てしまう(リーク)。Day009のK-FoldやDay098のラグ特徴量でも触れた「時系列は時間の順序を守って分割する」という原則がLSTMでも変わらず重要になる。

📊 RMSE比較の可視化(val、値が小さいほど良い)

Naive(1つ前の値をそのまま使う)
RMSE 15.1(例)
週次の谷/山を外しやすい
LSTM(過去14日を学習)
RMSE 6.8(例)
週次パターンを反映
🏁
週次季節性を含む系列では「1つ前の値をそのまま使う」ナイーブ予測は、周期の山・谷をそのまま1日ずらして外してしまうため誤差が大きくなりやすい。14日分の文脈(1〜2周期分)を読んでいるLSTMは、この周期パターンを学習できる分だけ明確にRMSEが下がりやすい。ただし実際の数値は乱数シード・ハイパーパラメータで変動するため、必ず自分の環境で再現して確認すること。

🪜 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
🔍
なぜこうするか: valやtestの平均・標準偏差まで使って標準化すると、「まだ見ていないはずの未来のデータの統計的な性質」がtrain側の前処理に混ざってしまう(リークの一種)。Day013(標準化と正規化)で学んだ「fitはtrainだけ、transformはtrain/val/test全部」という原則をここでも守る。

2Sliding Windowで「過去14日→翌日」のペアを大量に作る

def __getitem__(self, idx):
    x = self.series[idx : idx + self.window_size]
    y = self.series[idx + self.window_size]
🪟
なぜこうするか: LSTMは「固定長の時系列を1本読んで1つの答えを出す」形に入力を揃える必要がある。180日分の連続データそのままではモデルに渡せないため、開始位置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))
🧭
なぜこうするか: どんなに複雑なモデルを作っても、「何もしない単純な方法」に負けていては意味がない。時系列予測では「1つ前の値をそのまま使う」「7日前の値をそのまま使う」といったナイーブベースラインが驚くほど強いことが多く、これを上回れているかを必ず確認する習慣が重要(Day098でも触れたラグ特徴量の考え方の原点でもある)。

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

🔢
勾配消失を「掛け算の繰り返し」として直感的に理解する: 0.9という数字を1回掛けても0.9のままですが、10回掛けると約0.35、50回掛けると約0.005まで小さくなります。単純なRNNの学習では、これと似た「1未満の数の繰り返し掛け算」が時間を遡るステップ数だけ発生し、遠い過去の情報ほど「ほとんど影響しない」という扱いに近づいていきます。これが勾配消失の直感的なイメージです。LSTMのセル状態は、この掛け算の代わりに「足し算に近い」経路(ゲートが開いていれば情報がそのまま加算的に伝わる)を用意することで、遠い過去の情報も消えにくくしています。
📐
もし数式を見たら:
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テーマ予定)

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

Day114〜120でCVブロックが完了し、Day121から時系列ブロック(16-18: LSTM・Prophet・Temporal Fusion Transformer)が始まった。今日はその1本目、LSTMの基礎とSliding Window実装を学んだ。

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

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