主要な知見
  • RNNのコアイノベーションは隠れ状態の時間伝搬[1]にある——ニューラルネットワークに「記憶」を与え、任意の長さの系列を処理する能力を付与した
  • LSTM[2]は3つのゲート機構(忘却ゲート、入力ゲート、出力ゲート)により勾配消失問題[3]を解決し、GRU[4]はより簡潔な2ゲート構造で同等の性能を達成
  • Seq2Seq[6]からアテンション機構[7]まで、RNNは現代NLPのエンコーダー・デコーダーパラダイムを先駆け、最終的にTransformerアーキテクチャ[11]へと進化した
  • 本記事にはGoogle Colab実践ラボ2本を収録:LSTMシェイクスピア風テキスト生成、およびLSTM画像系列分類(MNIST画像を28ステップの時系列として処理)

1. 系列の力:なぜ世界はRNNを必要とするのか

ディープラーニングの歴史において、ある根本的な課題が存在してきた:ニューラルネットワークに「順序」をどう理解させるか? 従来の全結合ネットワークや畳み込みニューラルネットワークは固定サイズの入力を処理する——1枚の画像、1組の特徴量。しかし、現実世界は系列データに満ちている:言語は単語の系列であり、音声は音波の系列であり、株価は時間を通じた系列であり、動画はフレームの系列である。

1990年、Jeffrey Elmanは単純リカレントネットワーク(SRN)[1]を提案し、一見シンプルだが極めて重要な設計を導入した:前のタイムステップの隠れ状態を現在のタイムステップにフィードバックする。この「ループ」がネットワークに記憶を与えた——もはや現在の入力だけを見るのではなく、過去に見たものを「記憶」できるようになったのである。

RNNのコア数式は簡潔かつ優美である:

h_t = tanh(W_hh · h_{t-1} + W_xh · x_t + b_h)
y_t = W_hy · h_t + b_y

ここで:
  h_t = タイムステップtでの隠れ状態(記憶)
  x_t = タイムステップtでの入力
  y_t = タイムステップtでの出力
  W_hh, W_xh, W_hy = 重み行列(全タイムステップで共有)

この重み共有の設計はRNNの大きな利点である:系列の長さに関係なく、モデルのパラメータ数は一定に保たれる。学習済みのRNNは10単語の文も1000単語の記事も処理できる。

2. 勾配消失:RNNの致命的な弱点

理論的には、RNNは任意の長距離依存関係を捉えることができる。しかし実際には、Bengioらの1994年の研究[3]により厳しい現実が明らかになった:勾配消失問題により、標準的なRNNが10-20ステップを超える長期依存関係を学習することは事実上不可能である。

問題の根源は、時間逆伝搬法(BPTT)[12]の数学的性質にある。時間展開されたRNNにおいて、勾配は複数のタイムステップにわたる連鎖乗算を通過しなければならない:

∂L/∂h_0 = ∂L/∂h_T · ∂h_T/∂h_{T-1} · ... · ∂h_1/∂h_0

各 ∂h_t/∂h_{t-1} 項は W_hh の繰り返しの乗算を含む:
- W_hh の最大固有値 < 1 → 勾配が指数関数的に減衰(消失)
- W_hh の最大固有値 > 1 → 勾配が指数関数的に増大(爆発)

直感的に言えば、標準的なRNNに「最初の単語が何だったか」を記憶させて100番目の単語を予測させたい場合、勾配は99のタイムステップを遡る必要がある。各ステップで1未満の値を乗じると、99回の乗算後に勾配は事実上ゼロになり——ネットワークはこの長距離依存関係を「学習できない」のである。

3. LSTM:ゲート付き記憶の革命

1997年、Sepp HochreiterとJurgen SchmidhuberはLong Short-Term Memory(LSTM)[2]を提案し、洗練されたゲーティング機構により勾配消失問題をエレガントに解決した。LSTMのコアイノベーションは、情報が複数のタイムステップにわたって損失なく流れることを可能にする「記憶のハイウェイ」(セル状態)の導入である。

LSTMユニットは3つのゲートと1つの記憶チャネルを含む:

構成要素数式機能
忘却ゲート f_tσ(W_f · [h_{t-1}, x_t] + b_f)「何を忘れるか」を決定——セル状態から古い情報を除去
入力ゲート i_tσ(W_i · [h_{t-1}, x_t] + b_i)「何を記憶するか」を決定——セル状態に新しい情報を書き込む
候補記憶 C_ttanh(W_C · [h_{t-1}, x_t] + b_C)新しい候補記憶内容を生成
セル状態 C_tf_t ⊙ C_{t-1} + i_t ⊙ C_t記憶を更新:古いものを忘却 + 新しいものを追加
出力ゲート o_tσ(W_o · [h_{t-1}, x_t] + b_o)「何を出力するか」を決定——セル状態から読み出す
隠れ状態 h_to_t ⊙ tanh(C_t)現在のタイムステップの出力

なぜLSTMは勾配消失問題を解決できるのか? その鍵はセル状態の更新式 C_t = f_t ⊙ C_{t-1} + i_t ⊙ C_t にある。これは乗算ではなく加算構造であり——勾配は連続的な乗算なしにセル状態に沿って直接流れることができる。忘却ゲートが1に近い場合、勾配はほぼ損失なく遠い過去まで伝搬する。

4. GRU:より簡潔なゲーティング設計

2014年、ChoらはGated Recurrent Unit(GRU)[4]を提案した。これはLSTMの簡略版と見なすことができる。GRUは忘却ゲートと入力ゲートを1つの更新ゲートに統合し、独立したセル状態を排除することで、パラメータ数を約25%削減した。

# GRU コア数式
z_t = σ(W_z · [h_{t-1}, x_t])     # 更新ゲート:古い記憶をどれだけ保持するか
r_t = σ(W_r · [h_{t-1}, x_t])     # リセットゲート:古い記憶をどれだけ忘れるか
h̃_t = tanh(W · [r_t ⊙ h_{t-1}, x_t])  # 候補隠れ状態
h_t = (1 - z_t) ⊙ h_{t-1} + z_t ⊙ h̃_t  # 隠れ状態の更新

Chungらの実証研究[16]では、ほとんどの系列タスクでGRUとLSTMの性能が同等であることが示された。GRUの利点は学習速度の高速化とメモリ消費の低減にあり、リソースに制約のあるシナリオに適している。

5. 高度なRNNアーキテクチャ:双方向、スタック、Seq2Seq

5.1 双方向RNN

SchusterとPaliwalが提案した双方向RNN[5]は、順方向(過去→現在)と逆方向(未来→現在)の2方向の隠れ状態を同時に使用する。最終的な隠れ表現は両方向の連結であり、モデルが過去と未来の両方のコンテキストを活用できる。固有表現認識や品詞タグ付けなどのタスクでは、双方向LSTMが長らく標準構成であった。

5.2 深層/スタック型RNN

Gravesら[8]は、複数のRNN層を積み重ね(各層の出力を次の層の入力とする)ることで、より抽象的な系列表現を学習できることを実証した。ドロップアウト[14]による正則化と組み合わせることで、深層LSTMは音声認識においてブレークスルーとなる結果を達成した。

5.3 Seq2Seqとアテンション機構

2014年、Sutskeverら[6]はSequence-to-Sequence(Seq2Seq)アーキテクチャを提案した:LSTM エンコーダーが入力系列を固定次元のベクトルに圧縮し、別のLSTM デコーダーがそのベクトルから出力系列を生成する。このアーキテクチャは機械翻訳で顕著な成功を収めた。

しかし、入力系列全体を単一の固定長ベクトルに圧縮することで情報のボトルネックが生じる。2015年、Bahdanauら[7]アテンション機構を導入し、デコーダーが各単語を生成する際にエンコーダーの全隠れ状態を「振り返る」ことを可能にした。最も関連性の高い入力部分に動的に注目するこの仕組みは、翻訳品質を劇的に向上させただけでなく、後のTransformer[11]への道を拓いた。

6. RNNの応用領域

応用分野入力→出力パターン代表的アーキテクチャ主要ブレークスルー
音声認識系列→系列深層BiLSTM + CTC[15]CTC損失によりアライメント不要のエンドツーエンド学習を実現
機械翻訳系列→系列Seq2Seq + Attentionアテンション機構[7]が長文系列翻訳のボトルネックを解決
テキスト生成系列→系列文字/単語レベルLSTM[10]KarpathyがRNNでコードや数式の構造を学習できることを実証
画像キャプション画像→系列CNN + LSTM[9]視覚特徴抽出と言語生成の統合
感情分析系列→カテゴリBiLSTM + Attention双方向コンテキスト + キーワードへのアテンション集中
時系列予測系列→値スタック型LSTM多層抽象化により長短期トレンドを捕捉

7. 実践ラボ1:LSTMシェイクスピア風テキスト生成(Google Colab)

このラボでは、文字レベルLSTMモデルを学習させてシェイクスピア風テキストを生成する[10]。モデルは文字間の統計的パターンを学習し、1文字ずつ完全に新しいテキストを生成する。

# ============================================================
# 実践ラボ1:LSTM文字レベルテキスト生成
# 環境:Google Colab(無料GPU)
# 推定実行時間:約8分
# ============================================================

import torch
import torch.nn as nn
import numpy as np

# ------ 1. データ準備:シェイクスピアテキストのダウンロード ------
import urllib.request
url = "https://raw.githubusercontent.com/karpathy/char-rnn/master/data/tinyshakespeare/input.txt"
urllib.request.urlretrieve(url, "shakespeare.txt")

with open("shakespeare.txt", "r") as f:
    text = f.read()

print(f"Text length: {len(text):,} characters")
print(f"First 200 characters:\n{text[:200]}")

# 文字 ↔ インデックスのマッピング構築
chars = sorted(set(text))
vocab_size = len(chars)
char_to_idx = {c: i for i, c in enumerate(chars)}
idx_to_char = {i: c for i, c in enumerate(chars)}
print(f"Vocabulary size: {vocab_size} unique characters")

# テキストのエンコード
data = np.array([char_to_idx[c] for c in text])

# ------ 2. 学習データセットの構築 ------
seq_length = 100
batch_size = 64

def get_batch(data, seq_length, batch_size):
    """バッチ系列をランダムサンプリング"""
    max_start = len(data) - seq_length - 1
    starts = np.random.randint(0, max_start, size=batch_size)
    x = np.array([data[s:s+seq_length] for s in starts])
    y = np.array([data[s+1:s+seq_length+1] for s in starts])
    return torch.tensor(x, dtype=torch.long), torch.tensor(y, dtype=torch.long)

# ------ 3. LSTMモデルの定義 ------
class CharLSTM(nn.Module):
    def __init__(self, vocab_size, embed_dim=128, hidden_dim=256, num_layers=2, dropout=0.3):
        super().__init__()
        self.hidden_dim = hidden_dim
        self.num_layers = num_layers
        self.embed = nn.Embedding(vocab_size, embed_dim)
        self.lstm = nn.LSTM(embed_dim, hidden_dim, num_layers,
                           batch_first=True, dropout=dropout)
        self.fc = nn.Linear(hidden_dim, vocab_size)

    def forward(self, x, hidden=None):
        emb = self.embed(x)                    # (batch, seq, embed)
        out, hidden = self.lstm(emb, hidden)    # (batch, seq, hidden)
        logits = self.fc(out)                   # (batch, seq, vocab)
        return logits, hidden

    def init_hidden(self, batch_size, device):
        h = torch.zeros(self.num_layers, batch_size, self.hidden_dim, device=device)
        c = torch.zeros(self.num_layers, batch_size, self.hidden_dim, device=device)
        return (h, c)

# ------ 4. 学習 ------
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Using device: {device}")

model = CharLSTM(vocab_size).to(device)
optimizer = torch.optim.Adam(model.parameters(), lr=0.002)
criterion = nn.CrossEntropyLoss()

print(f"Model parameters: {sum(p.numel() for p in model.parameters()):,}")

num_steps = 3000
for step in range(1, num_steps + 1):
    model.train()
    x, y = get_batch(data, seq_length, batch_size)
    x, y = x.to(device), y.to(device)

    logits, _ = model(x)
    loss = criterion(logits.view(-1, vocab_size), y.view(-1))

    optimizer.zero_grad()
    loss.backward()
    nn.utils.clip_grad_norm_(model.parameters(), 5.0)  # 勾配爆発を防止
    optimizer.step()

    if step % 500 == 0:
        print(f"Step {step}/{num_steps}, Loss: {loss.item():.4f}")

# ------ 5. テキスト生成 ------
def generate(model, start_text="ROMEO:\n", length=500, temperature=0.8):
    """学習済みモデルでテキスト生成"""
    model.eval()
    chars_idx = [char_to_idx[c] for c in start_text]
    hidden = model.init_hidden(1, device)

    # start_textでコンテキストを構築
    for ch_idx in chars_idx[:-1]:
        inp = torch.tensor([[ch_idx]], device=device)
        _, hidden = model(inp, hidden)

    generated = list(start_text)
    inp = torch.tensor([[chars_idx[-1]]], device=device)

    with torch.no_grad():
        for _ in range(length):
            logits, hidden = model(inp, hidden)
            logits = logits[0, -1] / temperature
            probs = torch.softmax(logits, dim=0)
            next_idx = torch.multinomial(probs, 1).item()
            generated.append(idx_to_char[next_idx])
            inp = torch.tensor([[next_idx]], device=device)

    return "".join(generated)

# 異なるtemperature値の効果
for temp in [0.5, 0.8, 1.2]:
    print(f"\n{'='*60}")
    print(f"Temperature = {temp}")
    print(f"{'='*60}")
    print(generate(model, temperature=temp))

Temperatureパラメータの意義: temperature < 1 は出力をより保守的で「正確」にするが多様性は低下する。temperature > 1 はランダム性と創造性を高めるが、非一貫的なテキストを生成する可能性がある。Temperature = 0.8 が一般的に品質と多様性のスイートスポットである。

8. 実践ラボ2:LSTM画像系列分類——MNISTを時系列として扱う(Google Colab)

RNNはテキストだけでなく、系列として表現できるあらゆるデータがRNNの舞台である。このラボでは、MNIST 28x28画像を28タイムステップ、各ステップ28ピクセル値の入力として扱う。LSTMが画像を行ごとにスキャンし、蓄積された隠れ状態に基づいて分類を行う[13]

# ============================================================
# 実践ラボ2:LSTM画像系列分類(MNISTを系列として処理)
# 環境:Google Colab(無料GPU)
# 推定実行時間:約5分
# ============================================================

import torch
import torch.nn as nn
from torchvision import datasets, transforms
from torch.utils.data import DataLoader
import matplotlib.pyplot as plt
import numpy as np

# ------ 1. MNISTデータセットの読み込み ------
transform = transforms.Compose([
    transforms.ToTensor(),
    transforms.Normalize((0.1307,), (0.3081,))
])
train_data = datasets.MNIST('./data', train=True, download=True, transform=transform)
test_data = datasets.MNIST('./data', train=False, transform=transform)
train_loader = DataLoader(train_data, batch_size=128, shuffle=True)
test_loader = DataLoader(test_data, batch_size=256, shuffle=False)
print(f"Training set: {len(train_data)} images, Test set: {len(test_data)} images")

# ------ 2. 可視化:MNISTを系列として表示 ------
fig, axes = plt.subplots(1, 3, figsize=(14, 4))
sample_img = train_data[0][0].squeeze().numpy()

# 元画像
axes[0].imshow(sample_img, cmap='gray')
axes[0].set_title("Original 28×28 Image", fontsize=12)

# 行ごとに系列として展開
seq_view = sample_img.copy()
for i in range(0, 28, 4):
    axes[1].axhline(y=i, color='cyan', alpha=0.3, linewidth=0.5)
axes[1].imshow(seq_view, cmap='gray')
axes[1].set_title("LSTM Row-by-Row Scan (28 steps × 28 features)", fontsize=12)
for i, arrow_y in enumerate(range(2, 26, 3)):
    axes[1].annotate('→', xy=(26, arrow_y), fontsize=8, color='cyan', alpha=0.6)

# 最初の数行のピクセル値
axes[2].plot(sample_img[:8].T, alpha=0.7)
axes[2].set_xlabel("Pixel Position (0-27)")
axes[2].set_ylabel("Pixel Value")
axes[2].set_title("Pixel Value Sequences for First 8 Rows", fontsize=12)
axes[2].legend([f"row {i}" for i in range(8)], fontsize=7, ncol=2)
plt.tight_layout()
plt.savefig("mnist_as_sequence.png", dpi=150, bbox_inches='tight')
plt.show()

# ------ 3. LSTM分類モデルの定義 ------
class ImageLSTM(nn.Module):
    """
    28×28画像を28ステップの系列として扱い、各ステップ28次元の特徴量。
    LSTMが行ごとに読み取り、最後の隠れ状態で分類を行う。
    """
    def __init__(self, input_size=28, hidden_size=128, num_layers=2,
                 num_classes=10, dropout=0.3, bidirectional=True):
        super().__init__()
        self.hidden_size = hidden_size
        self.num_layers = num_layers
        self.bidirectional = bidirectional
        self.num_directions = 2 if bidirectional else 1

        self.lstm = nn.LSTM(input_size, hidden_size, num_layers,
                           batch_first=True, dropout=dropout,
                           bidirectional=bidirectional)
        self.dropout = nn.Dropout(dropout)
        self.fc = nn.Linear(hidden_size * self.num_directions, num_classes)

    def forward(self, x):
        # x: (batch, 1, 28, 28) → (batch, 28, 28)
        x = x.squeeze(1)  # チャネル次元を除去

        # LSTMで系列を処理:28ステップ、各28次元
        out, (h_n, c_n) = self.lstm(x)

        # 最後のステップの出力で分類
        if self.bidirectional:
            # 順方向と逆方向の最後の隠れ状態を連結
            last_hidden = torch.cat([h_n[-2], h_n[-1]], dim=1)
        else:
            last_hidden = h_n[-1]

        out = self.dropout(last_hidden)
        logits = self.fc(out)
        return logits

# ------ 4. 学習 ------
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = ImageLSTM().to(device)
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
criterion = nn.CrossEntropyLoss()
print(f"Model parameters: {sum(p.numel() for p in model.parameters()):,}")
print(f"Using device: {device}")

num_epochs = 10
train_losses, test_accs = [], []

for epoch in range(1, num_epochs + 1):
    model.train()
    total_loss = 0
    for images, labels in train_loader:
        images, labels = images.to(device), labels.to(device)
        logits = model(images)
        loss = criterion(logits, labels)

        optimizer.zero_grad()
        loss.backward()
        nn.utils.clip_grad_norm_(model.parameters(), 5.0)
        optimizer.step()
        total_loss += loss.item()

    avg_loss = total_loss / len(train_loader)
    train_losses.append(avg_loss)

    # テスト
    model.eval()
    correct, total = 0, 0
    with torch.no_grad():
        for images, labels in test_loader:
            images, labels = images.to(device), labels.to(device)
            preds = model(images).argmax(dim=1)
            correct += (preds == labels).sum().item()
            total += labels.size(0)
    acc = correct / total
    test_accs.append(acc)
    print(f"Epoch {epoch}/{num_epochs}, Loss: {avg_loss:.4f}, Test Acc: {acc:.4f}")

# ------ 5. 学習過程の可視化 ------
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4))
ax1.plot(train_losses, 'b-', linewidth=2)
ax1.set_xlabel("Epoch"); ax1.set_ylabel("Loss"); ax1.set_title("Training Loss")
ax1.grid(True, alpha=0.3)

ax2.plot([a*100 for a in test_accs], 'r-', linewidth=2)
ax2.set_xlabel("Epoch"); ax2.set_ylabel("Accuracy (%)"); ax2.set_title("Test Accuracy")
ax2.grid(True, alpha=0.3)
ax2.set_ylim([90, 100])
plt.tight_layout()
plt.savefig("lstm_mnist_training.png", dpi=150, bbox_inches='tight')
plt.show()

print(f"\nFinal test accuracy: {test_accs[-1]*100:.2f}%")
print("BiLSTM can still achieve ~98% accuracy treating images as sequences!")

# ------ 6. 隠れ状態の変化を可視化 ------
model.eval()
sample = test_data[0][0].unsqueeze(0).to(device)

# 各タイムステップでの隠れ状態を抽出
with torch.no_grad():
    x = sample.squeeze(1)  # (1, 28, 28)
    h_states = []
    h = None
    for t in range(28):
        step_input = x[:, t:t+1, :]  # (1, 1, 28)
        out, h = model.lstm(step_input, h)
        h_states.append(h[0][-1].cpu().numpy())  # 最後の層のhを取得

h_states = np.array(h_states).squeeze()  # (28, hidden_size)

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5))
ax1.imshow(sample.cpu().squeeze(), cmap='gray')
ax1.set_title("Input Image", fontsize=12)

im = ax2.imshow(h_states.T[:32], aspect='auto', cmap='RdYlBu_r')
ax2.set_xlabel("Time Step (Row Scan)")
ax2.set_ylabel("Hidden Units (First 32)")
ax2.set_title("LSTM Hidden State Evolution Over Time Steps", fontsize=12)
plt.colorbar(im, ax=ax2)
plt.tight_layout()
plt.savefig("lstm_hidden_states.png", dpi=150, bbox_inches='tight')
plt.show()

なぜ画像を系列として扱うことに意義があるのか? この実験の教育的価値は以下にある:(1) LSTMが純粋に系列的な観点から空間構造を「理解」できることを実証、(2) BiLSTMが約98%の精度を達成し、行ごとのスキャンが十分な空間情報を捕捉できることを証明、(3) 隠れ状態の可視化によりLSTMが手書き数字を処理する際の内部ダイナミクスが明らかになる。

9. RNNからTransformerへ:歴史的なリレー

2017年、Vaswaniらは画期的な論文「Attention Is All You Need」[11]を発表し、RNNの再帰構造を完全に放棄した、セルフアテンション機構に完全に基づくTransformerアーキテクチャを提案した。Transformerの利点は以下の通りである:

ただし、RNNが完全に置き換えられたわけではない。以下のシナリオではRNNに依然として優位性がある:

シナリオRNNの優位性Transformerの優位性
リアルタイムストリーミングステップバイステップ入力に自然に適合完全な系列または特殊な設計が必要
超長系列(>10Kトークン)1ステップあたりO(1)のメモリセルフアテンションにO(n²)のメモリが必要
組み込み/エッジデバイス小型モデル、高速推論通常は大量のパラメータが必要
因果的系列モデリング自然な因果構造因果マスキングが必要

10. 結論:記憶の力

ElmanのSimple Recurrent Network[1]から、HochreiterとSchmidhuberのLSTM[2]、Bahdanauのアテンション機構[7]まで、RNNの発展史はディープラーニングにおける最も魅力的な章の一つである。各ブレークスルーは「記憶」という根本的問題へのより深い理解から生まれた:

RNNを理解することは単なる歴史的考古学ではない——現代のディープラーニングを理解するための基盤である。Transformerの多くのコアコンセプト(エンコーダー・デコーダー、アテンション、系列モデリング)はRNN研究の伝統に由来する。RNNを習得すれば、GPT、BERT、そしてLLMの世界への鍵を手にすることになる[13]