Key Findings
  • RNN 的核心創新在於隱藏狀態的時間傳遞[1]——讓神經網路擁有「記憶」,能處理任意長度的序列資料
  • LSTM[2] 以遺忘門、輸入門、輸出門的三門控機制解決了梯度消失問題[3];GRU[4] 則以更精簡的雙門結構達到相當效能
  • 從 Seq2Seq[6] 到注意力機制[7],RNN 開創了現代 NLP 的 Encoder-Decoder 範式,最終演進為 Transformer[11]
  • 本文附兩個 Google Colab 實作:LSTM 莎士比亞風格文字生成、LSTM 影像序列分類(將 MNIST 圖像視為 28 步時間序列)

一、序列的力量:為何世界需要 RNN

在深度學習的發展史中,有一個根本的挑戰:如何讓神經網路理解「順序」?傳統的全連接網路和 CNN 處理的是固定大小的輸入——一張圖片、一組特徵。但現實世界充滿了序列資料:語言是詞的序列、語音是聲波的序列、股價是時間的序列、影片是影格的序列。

1990 年,Jeffrey Elman 提出了簡單循環網路(Simple Recurrent Network, 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 個詞的文章。

二、梯度消失:RNN 的致命弱點

理論上,RNN 可以捕捉任意長距離的依賴關係。但在實務中,Bengio 等人在 1994 年的研究[3]揭示了一個殘酷的現實:梯度消失(Vanishing Gradient)問題讓標準 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 次乘法後梯度幾乎為零——網路根本「學不到」這個長距離依賴。

三、LSTM:門控記憶的革命

1997 年,Sepp Hochreiter 和 Jürgen Schmidhuber 提出了長短期記憶網路(Long Short-Term Memory, LSTM)[2],以精巧的門控機制徹底解決了梯度消失問題。LSTM 的核心創新是引入了一條「記憶高速公路」(cell state),讓資訊可以無損地跨越多個時間步。

LSTM 單元包含三個門和一條記憶通道:

組件公式功能
遺忘門 f_tσ(W_f · [h_{t-1}, x_t] + b_f)決定「忘記什麼」——從 cell state 中移除過時資訊
輸入門 i_tσ(W_i · [h_{t-1}, x_t] + b_i)決定「記住什麼」——將新資訊寫入 cell state
候選記憶 C̃_ttanh(W_C · [h_{t-1}, x_t] + b_C)生成新的候選記憶內容
Cell State C_tf_t ⊙ C_{t-1} + i_t ⊙ C̃_t更新記憶:忘記舊的 + 加入新的
輸出門 o_tσ(W_o · [h_{t-1}, x_t] + b_o)決定「輸出什麼」——從 cell state 中讀取
隱藏狀態 h_to_t ⊙ tanh(C_t)當前時間步的輸出

LSTM 為何能解決梯度消失?關鍵在於 Cell State 的更新公式 C_t = f_t ⊙ C_{t-1} + i_t ⊙ C̃_t。這是一個加法結構而非乘法——梯度可以沿著 Cell State 直接流動,不需要連續相乘。當遺忘門接近 1 時,梯度幾乎無損地傳播到遙遠的過去。

四、GRU:更精簡的門控設計

2014 年,Cho 等人提出了門控循環單元(Gated Recurrent Unit, GRU)[4],可視為 LSTM 的精簡版本。GRU 將遺忘門和輸入門合併為一個更新門,並取消了獨立的 Cell State,使參數量減少約 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 的優勢在於更快的訓練速度和更少的記憶體消耗,適合資源受限的場景。

五、RNN 進階架構:雙向、堆疊、Seq2Seq

5.1 雙向 RNN(Bidirectional RNN)

Schuster 與 Paliwal[5]提出的雙向 RNN 同時使用兩個方向的隱藏狀態:前向(讀取過去→現在)和反向(讀取未來→現在)。最終的隱藏表示是兩個方向的拼接,讓模型同時利用上下文資訊。在命名實體識別、詞性標註等任務中,雙向 LSTM 是長期以來的標準配置。

5.2 深層 / 堆疊 RNN

Graves 等人[8]證明,將多層 RNN 堆疊起來(每層的輸出作為下一層的輸入)可以學習更抽象的序列表示。配合 Dropout[14] 正則化,深層 LSTM 在語音辨識上達到了突破性的成果。

5.3 Seq2Seq 與注意力機制

2014 年,Sutskever 等人[6]提出了 Sequence-to-Sequence(Seq2Seq)架構:一個 LSTM Encoder 將輸入序列壓縮為固定維度的向量,另一個 LSTM Decoder 從這個向量生成輸出序列。這個架構在機器翻譯上取得了驚人的成功。

然而,將整個輸入序列壓縮為一個固定長度向量是一個資訊瓶頸。2015 年,Bahdanau 等人[7]引入了注意力機制(Attention),讓 Decoder 在生成每個詞時可以「回頭看」Encoder 的所有隱藏狀態,動態地聚焦於最相關的輸入部分。注意力機制不僅大幅提升了翻譯品質,更為後來的 Transformer[11] 鋪平了道路。

六、RNN 的應用光譜

應用領域輸入→輸出模式代表性架構關鍵突破
語音辨識序列→序列深層 BiLSTM + CTC[15]CTC 損失實現無需對齊的端對端訓練
機器翻譯序列→序列Seq2Seq + Attention注意力機制[7]解決長序列翻譯瓶頸
文字生成序列→序列字元/詞級 LSTM[10]Karpathy 展示 RNN 可學習程式碼、數學公式的結構
圖像描述圖像→序列CNN + LSTM[9]結合視覺特徵提取與語言生成
情感分析序列→類別BiLSTM + Attention雙向上下文 + 注意力聚焦關鍵詞
時間序列預測序列→數值Stacked LSTM多層抽象捕捉長短期趨勢

七、Hands-on Lab 1:LSTM 莎士比亞風格文字生成(Google Colab)

在這個實作中,我們將訓練一個字元級 LSTM 模型來生成莎士比亞風格的文字[10]。模型學習字元之間的統計規律,然後逐字元生成全新的文本。

# ============================================================
# Hands-on Lab 1:LSTM 字元級文字生成
# 環境:Google Colab(免費 GPU)
# 預估執行時間:約 8 分鐘
# ============================================================

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

# ------ 1. 資料準備:下載 Shakespeare 文本 ------
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"文本長度: {len(text):,} 字元")
print(f"前 200 字元:\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"詞彙量: {vocab_size} 個不同字元")

# 編碼文本
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):
    """隨機取樣一個 batch 的序列"""
    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"使用裝置: {device}")

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

print(f"模型參數量: {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 建立 context
    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 通常是品質與多樣性的甜蜜點。

八、Hands-on Lab 2:LSTM 影像序列分類 —— 將 MNIST 視為時間序列(Google Colab)

RNN 不僅能處理文字——任何可以表示為序列的資料都是 RNN 的舞台。在這個實作中,我們將 MNIST 28×28 的影像視為28 個時間步,每步輸入 28 個像素值。LSTM 逐行「掃描」圖像,最後根據累積的隱藏狀態進行分類[13]

# ============================================================
# Hands-on Lab 2:LSTM 影像序列分類(MNIST as Sequence)
# 環境: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"訓練集: {len(train_data)} 張, 測試集: {len(test_data)} 張")

# ------ 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("原始 28×28 影像", 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 逐行掃描 (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("像素位置 (0-27)")
axes[2].set_ylabel("像素值")
axes[2].set_title("前 8 行的像素值序列", 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)  # 移除 channel 維度

        # 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"模型參數量: {sum(p.numel() for p in model.parameters()):,}")
print(f"使用裝置: {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"\n最終測試準確率: {test_accs[-1]*100:.2f}%")
print("BiLSTM 將圖像視為序列仍能達到 ~98% 準確率!")

# ------ 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("輸入影像", fontsize=12)

im = ax2.imshow(h_states.T[:32], aspect='auto', cmap='RdYlBu_r')
ax2.set_xlabel("時間步 (行掃描)")
ax2.set_ylabel("隱藏單元 (前 32 個)")
ax2.set_title("LSTM 隱藏狀態隨時間步的演變", 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) 雙向 LSTM 達到 ~98% 準確率,說明逐行掃描確實能捕捉足夠的空間資訊;(3) 隱藏狀態視覺化揭示了 LSTM 在處理手寫數字時的內部動態。

九、從 RNN 到 Transformer:歷史的接力

2017 年,Vaswani 等人發表了劃時代的論文〈Attention Is All You Need〉[11],提出了完全基於自注意力機制的 Transformer 架構,徹底摒棄了 RNN 的遞歸結構。Transformer 的優勢在於:

然而,RNN 並未完全被取代。在以下場景中,RNN 仍然具有優勢:

場景RNN 優勢Transformer 優勢
即時串流處理天然適合逐步輸入需要完整序列或特殊設計
極長序列(>10K tokens)記憶體 O(1) per step自注意力 O(n²) 記憶體
嵌入式 / 邊緣裝置模型小、推論快通常需要大量參數
因果序列建模天然的因果結構需要因果遮罩

十、結語:記憶的力量

從 Elman 的簡單循環網路[1]到 Hochreiter 與 Schmidhuber 的 LSTM[2],再到 Bahdanau 的注意力機制[7],RNN 的發展史是深度學習中最精彩的篇章之一。每一次突破都源於對「記憶」這個基本問題的更深理解:

理解 RNN 不只是歷史考古——它是理解現代深度學習的基石。Transformer 中的許多核心概念(Encoder-Decoder、注意力、序列建模)都源自 RNN 的研究傳統。掌握 RNN,你就掌握了通往 GPT、BERT、LLM 世界的鑰匙[13]