一、序列的力量:為何世界需要 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̃_t | tanh(W_C · [h_{t-1}, x_t] + b_C) | 生成新的候選記憶內容 |
| Cell State C_t | f_t ⊙ C_{t-1} + i_t ⊙ C̃_t | 更新記憶:忘記舊的 + 加入新的 |
| 輸出門 o_t | σ(W_o · [h_{t-1}, x_t] + b_o) | 決定「輸出什麼」——從 cell state 中讀取 |
| 隱藏狀態 h_t | o_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 必須按順序處理每個時間步;Transformer 可以同時處理整個序列
- 長距離依賴:自注意力機制讓任意兩個位置之間的距離為 O(1),而 RNN 為 O(n)
- 可擴展性:Transformer 的效能隨模型規模和資料量穩定提升
然而,RNN 並未完全被取代。在以下場景中,RNN 仍然具有優勢:
| 場景 | RNN 優勢 | Transformer 優勢 |
|---|---|---|
| 即時串流處理 | 天然適合逐步輸入 | 需要完整序列或特殊設計 |
| 極長序列(>10K tokens) | 記憶體 O(1) per step | 自注意力 O(n²) 記憶體 |
| 嵌入式 / 邊緣裝置 | 模型小、推論快 | 通常需要大量參數 |
| 因果序列建模 | 天然的因果結構 | 需要因果遮罩 |
十、結語:記憶的力量
從 Elman 的簡單循環網路[1]到 Hochreiter 與 Schmidhuber 的 LSTM[2],再到 Bahdanau 的注意力機制[7],RNN 的發展史是深度學習中最精彩的篇章之一。每一次突破都源於對「記憶」這個基本問題的更深理解:
- RNN 回答了「如何讓網路記住過去」
- LSTM 回答了「如何選擇性地記住和遺忘」
- 注意力機制回答了「如何動態地聚焦於相關資訊」
- Transformer 回答了「如何讓所有位置平等地交流」
理解 RNN 不只是歷史考古——它是理解現代深度學習的基石。Transformer 中的許多核心概念(Encoder-Decoder、注意力、序列建模)都源自 RNN 的研究傳統。掌握 RNN,你就掌握了通往 GPT、BERT、LLM 世界的鑰匙[13]。