Skip to content

PyTorch 系列:RNN 模型

這是 Deep Learning 系列的其第三篇,本文將深入解析 PyTorch 中的循環神經網絡 (Recurrent Neural Networks, RNNs)。我們會從 RNN 的基本原理開始,探討其進階變體如 LSTM 和 GRU,並透過 PyTorch 實戰範例來理解其應用。最後,我們會介紹更進階的序列到序列 (Seq2Seq) 模型概念及其演進。

❓ 為什麼需要 RNN?

傳統的前饋神經網絡 (Feedforward Neural Networks) 在處理很多問題時表現出色,但它們有一個基本假設:輸入數據點之間是相互獨立的。然而,在現實世界中,很多數據都是序列性的,例如:

  • 文字:一句話中的詞語順序很重要。
  • 語音:聲音訊號是隨時間變化的序列。
  • 時間序列數據:股票價格、天氣預報等。

對於這類序列數據,數據點的順序和前後文關係包含了重要資訊。傳統神經網絡無法有效捕捉這些時間依賴性 (temporal dependencies)。這就是 RNN 發揮作用的地方。

🧠 什麼是循環神經網絡 (Recurrent Neural Network, RNN)?

RNN 的核心思想是引入「記憶」或「狀態」(state) 的概念。網絡中的神經元不僅接收當前的輸入,還會接收來自上一個時間步 (time step) 的隱藏狀態 (hidden state)。這個隱藏狀態可以看作是網絡對先前序列資訊的總結。

簡單來說,RNN 在處理序列中的每個元素時,都會執行以下操作:

  1. 接收當前時間步的輸入
  2. 接收前一時間步的隱藏狀態
  3. 計算當前時間步的輸出 (可選)和新的隱藏狀態
  4. 這個新的隱藏狀態 會傳遞到下一個時間步。

這種循環結構使得 RNN 能夠在序列的不同位置共享權重 (weights),並學習序列中的模式。

RNN structure (https://en.wikipedia.org/wiki/File:Recurrent_neural_network_unfold.svg)

RNN 結構圖(來源:wikipedia

值得注意的是,RNN 處理序列數據時,是逐個元素依序處理的。對於單一序列而言,目前時間步的計算依賴於前一個時間步的隱藏狀態,這種依賴性使得 RNN 在處理單一序列的內部元素時,本質上是循序的 (sequential),難以像處理圖像的卷積神經網絡 (CNN) 那樣對序列內的不同部分進行大規模並行運算。當然,在處理一個批次 (batch) 中的多個不同序列時,這些序列之間是可以並行處理的,但每個序列內部的處理仍然是循序的。

RNN 的應用場景包括:

  • 自然語言處理 (NLP):文本生成、機器翻譯、情感分析。
  • 語音識別。
  • 時間序列預測。
  • 影片分析。

🛠️ PyTorch 中的 RNN 基礎

PyTorch 提供了 torch.nn.RNN 模組來方便地建立 RNN 層。

一個基本的 RNN 層主要有以下參數:

  • input_size:輸入特徵的維度 (dimension)。
  • hidden_size:隱藏狀態的維度。
  • num_layers:RNN 的層數 (堆疊 RNN)。預設為 1。
  • nonlinearity:使用的非線性激活函數,可以是 'tanh' 或 'relu'。預設為 'tanh'。
  • batch_first:如果為 True,則輸入和輸出的張量 (tensors) 格式為 (batch, seq, feature),否則為 (seq, batch, feature)。預設為 False

讓我們看一個簡單的 PyTorch RNN 範例:

python
import torch
import torch.nn as nn

# 參數設定
input_size = 10  # 輸入特徵維度
hidden_size = 20 # 隱藏狀態維度
num_layers = 1   # RNN 層數
seq_length = 5   # 序列長度
batch_size = 3   # 批次大小

# 建立 RNN 模型
rnn_layer = nn.RNN(input_size, hidden_size, num_layers, batch_first=True)

# 準備輸入數據 (batch_size, seq_length, input_size)
# 假設我們有 3 個序列,每個序列長度為 5,每個時間步的輸入特徵為 10 維
input_tensor = torch.randn(batch_size, seq_length, input_size)

# 初始隱藏狀態 (num_layers * num_directions, batch_size, hidden_size)
# num_directions 在單向 RNN 中為 1
h0 = torch.randn(num_layers, batch_size, hidden_size)

# 前向傳播
output, hn = rnn_layer(input_tensor, h0)

print("Input Tensor Shape:", input_tensor.shape)
print("Output Tensor Shape:", output.shape) # (batch_size, seq_length, hidden_size)
print("Final Hidden State Shape:", hn.shape) # (num_layers, batch_size, hidden_size)

在這個例子中:

  • output 張量包含了序列中每個時間步的隱藏狀態。如果 batch_first=True,其形狀為 (batch_size, seq_length, hidden_size)
  • hn 張量是最後一個時間步的隱藏狀態,形狀為 (num_layers, batch_size, hidden_size)

📉 RNN 的挑戰:梯度消失與梯度爆炸

儘管 RNN 理論上可以處理任意長度的序列,但在實踐中,標準的 RNN (也稱為 Vanilla RNN) 難以學習長距離依賴關係 (long-range dependencies)。這主要是由於 梯度消失 (vanishing gradients)梯度爆炸 (exploding gradients) 問題。這種循序處理的特性,也加劇了長序列訓練時的梯度傳播困難。

  • 梯度消失:在反向傳播過程中,梯度會隨著時間步的增加而指數級縮小,導致網絡難以學習到早期時間步的資訊對後期輸出的影響。
  • 梯度爆炸:相反地,梯度也可能指數級增大,導致訓練不穩定。

這些問題限制了標準 RNN 在處理長序列時的效能。為了解決這些問題,研究人員提出了更複雜的 RNN 架構,如 LSTM 和 GRU。

💡 長短期記憶網絡 (Long Short-Term Memory, LSTM)

LSTM 是一種特殊的 RNN,由 Hochreiter 和 Schmidhuber 於 1997 年提出,專門設計來解決梯度消失問題,使其能夠更好地學習長距離依賴。儘管 LSTM 內部結構更複雜,但其處理序列中每個元素的基本方式仍然是循序的。

LSTM 的核心思想是引入一個細胞狀態 (cell state),以及三個門控機制 (gating mechanisms) 來控制資訊在細胞狀態中的流動:

  1. 遺忘門 (Forget Gate):決定從細胞狀態中丟棄哪些資訊。它會查看 ,並為細胞狀態 中的每個數字輸出一一個介於 0 和 1 之間的值。1 代表「完全保留」,0 代表「完全丟棄」。
  2. 輸入門 (Input Gate):決定哪些新的資訊要儲存到細胞狀態中。它包含兩部分:一個 sigmoid 層決定哪些值需要更新,一個 tanh 层創建新的候選值
  3. 輸出門 (Output Gate):決定細胞狀態的哪些部分要作為輸出 。首先,一個 sigmoid 層決定細胞狀態的哪些部分要輸出。然後,細胞狀態通過 tanh 處理(將值縮放到 -1 和 1 之間)並與 sigmoid 門的輸出相乘。

新的細胞狀態 的計算方式是:

這些門控機制使得 LSTM 能夠選擇性地記憶或遺忘資訊,從而有效地捕捉長距離依賴。

PyTorch 中的 LSTM: PyTorch 提供了 torch.nn.LSTM 模組,其使用方式與 torch.nn.RNN 非常相似。

python
import torch
import torch.nn as nn

# 參數設定 (與 RNN 範例相同)
input_size = 10
hidden_size = 20
num_layers = 1
seq_length = 5
batch_size = 3

# 建立 LSTM 模型
lstm_layer = nn.LSTM(input_size, hidden_size, num_layers, batch_first=True)

# 準備輸入數據
input_tensor = torch.randn(batch_size, seq_length, input_size)

# 初始隱藏狀態和細胞狀態
# h0: (num_layers * num_directions, batch_size, hidden_size)
# c0: (num_layers * num_directions, batch_size, hidden_size)
h0 = torch.randn(num_layers, batch_size, hidden_size)
c0 = torch.randn(num_layers, batch_size, hidden_size)

# 前向傳播
output, (hn, cn) = lstm_layer(input_tensor, (h0, c0))

print("Input Tensor Shape:", input_tensor.shape)
print("Output Tensor Shape:", output.shape) # (batch_size, seq_length, hidden_size)
print("Final Hidden State Shape (hn):", hn.shape) # (num_layers, batch_size, hidden_size)
print("Final Cell State Shape (cn):", cn.shape)   # (num_layers, batch_size, hidden_size)

注意,LSTM 的前向傳播返回三個值:output,最後的隱藏狀態 hn,和最後的細胞狀態 cn。初始狀態也需要同時提供 h0c0

⚙️ 門控循環單元 (Gated Recurrent Unit, GRU)

GRU 由 Cho 等人於 2014 年提出,是 LSTM 的一個變體,旨在簡化 LSTM 的結構,同時保持其優良性能。GRU 同樣是循序處理序列元素。

GRU 主要有兩個門:

  1. 重設門 (Reset Gate) :決定如何將過去的資訊()與新的輸入候選 結合。如果重設門的元素接近 0,則會忽略過去的狀態。
  2. 更新門 (Update Gate) :決定在多大程度上保留過去的狀態 ,以及在多大程度上引入新的候選狀態

候選隱藏狀態 的計算方式是:

其中 表示元素級相乘。

最終的隱藏狀態 的計算方式是:

這裡, 控制著是從 中「遺忘」還是從 中「更新」。

GRU 的參數比 LSTM 少,因此計算上可能更有效率,訓練速度更快。在許多任務中,GRU 的表現與 LSTM 相當。

PyTorch 中的 GRU: PyTorch 提供了 torch.nn.GRU 模組,其使用方式與 torch.nn.RNN 類似,但初始狀態只需提供 h0 (因為它沒有獨立的細胞狀態)。

python
import torch
import torch.nn as nn

# 參數設定 (與 RNN 範例相同)
input_size = 10
hidden_size = 20
num_layers = 1
seq_length = 5
batch_size = 3

# 建立 GRU 模型
gru_layer = nn.GRU(input_size, hidden_size, num_layers, batch_first=True)

# 準備輸入數據
input_tensor = torch.randn(batch_size, seq_length, input_size)

# 初始隱藏狀態
# h0: (num_layers * num_directions, batch_size, hidden_size)
h0 = torch.randn(num_layers, batch_size, hidden_size)

# 前向傳播
output, hn = gru_layer(input_tensor, h0)

print("Input Tensor Shape:", input_tensor.shape)
print("Output Tensor Shape:", output.shape) # (batch_size, seq_length, hidden_size)
print("Final Hidden State Shape (hn):", hn.shape) # (num_layers, batch_size, hidden_size)

⚖️ RNN vs LSTM vs GRU:比較與選擇

特性RNN (Vanilla)LSTMGRU
基本單元單個 tanh/relu 層細胞狀態,遺忘門,輸入門,輸出門更新門,重設門
參數數量較少最多中等 (少於 LSTM)
計算複雜度低 (但因循序處理,長序列仍慢)高 (循序處理)中等 (循序處理,低於 LSTM)
長距離依賴差 (易受梯度消失/爆炸影響)好 (通常與 LSTM 相當)
訓練速度相對快 (但效果可能不好,受限於循序性)慢 (受限於循序性)較快 (相對於 LSTM,但仍受限於循序性)
序列內並行
適用場景非常短的序列,或作為教學理解RNN基礎複雜序列,需要精細控制資訊流動的任務大多數序列任務,作為 LSTM 的高效替代方案

如何選擇?

  • 從 GRU 或 LSTM 開始:對於大多數序列建模任務,標準 RNN 通常不是最佳選擇,因為梯度問題和其對長距離依賴的捕捉能力較弱。你可以先嘗試 GRU,因為它參數較少,訓練可能更快。
  • 數據量:如果數據量非常大,LSTM 更強大的表達能力可能會帶來優勢。如果數據量有限,GRU 的參數較少,可能更不容易過擬合 (overfitting)。
  • 計算資源與時間:所有這些模型在處理單一序列時都是循序的,這意味著處理長序列會比較耗時。GRU 通常比 LSTM 計算效率稍高。
  • 經驗法則:在許多情況下,LSTM 和 GRU 的表現相似。經驗上,可以都嘗試一下,看看哪個在你的特定任務和數據集上表現更好。如果沒有明顯差異,選擇計算效率更高的 GRU 可能更實際。

💻 PyTorch 實戰範例:字符級別序列預測

現在,我們來看一個字符級別序列預測的 PyTorch 實戰範例。這個例子雖然簡單,但有助於理解如何在 PyTorch 中使用基本的 RNN/LSTM/GRU 層。假設我們的詞彙表只有 'h', 'e', 'l', 'o' 四個字符。我們希望模型在看到 "hel" 後能預測 "l"。

python
import torch
import torch.nn as nn
import torch.optim as optim
import random # For teacher forcing in a Seq2Seq context, though not fully used here

# 0. 設定
vocab = ['h', 'e', 'l', 'o'] # 我們的詞彙表
char_to_idx = {char: idx for idx, char in enumerate(vocab)}
idx_to_char = {idx: char for idx, char in enumerate(vocab)}

input_size = len(vocab)  # 輸入維度 (詞彙表大小,使用 one-hot)
hidden_size = 12         # RNN 隱藏層維度
output_size = len(vocab) # 輸出維度 (預測下一個字符的概率分佈)
num_layers = 1           # RNN 層數
learning_rate = 0.01
num_epochs = 300         # 增加訓練週期以獲得更好效果

# 1. 準備數據
# 輸入序列 "hel", 目標序列 "ell" (即 "hel" 的每個字符的下一個字符)
input_str = "hel"
target_str = "ell" # h->e, e->l, l->l

# 將字符轉換為索引
input_seq_idx = torch.tensor([char_to_idx[c] for c in input_str])
target_seq_idx = torch.tensor([char_to_idx[c] for c in target_str])

# 將輸入索引序列轉換為 one-hot 編碼
# PyTorch RNN 預期輸入形狀:
# (seq_len, batch_size, input_size) if batch_first=False (default)
# (batch_size, seq_len, input_size) if batch_first=True
# 這裡我們使用 batch_size = 1, batch_first = False
input_one_hot = nn.functional.one_hot(input_seq_idx, num_classes=input_size).float().unsqueeze(1)
# input_one_hot shape: (seq_len=3, batch_size=1, input_size=4)
# target_seq_idx shape: (seq_len=3) 用於計算損失

# 2. 定義模型
class CharRNNPredictor(nn.Module):
    def __init__(self, input_dim, hidden_dim, output_dim, num_layers=1, rnn_type='lstm'):
        super(CharRNNPredictor, self).__init__()
        self.hidden_dim = hidden_dim
        self.num_layers = num_layers
        self.rnn_type = rnn_type.lower()
        self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

        if self.rnn_type == 'rnn':
            self.rnn = nn.RNN(input_dim, hidden_dim, num_layers, batch_first=False)
        elif self.rnn_type == 'lstm':
            self.rnn = nn.LSTM(input_dim, hidden_dim, num_layers, batch_first=False)
        elif self.rnn_type == 'gru':
            self.rnn = nn.GRU(input_dim, hidden_dim, num_layers, batch_first=False)
        else:
            raise ValueError("Unsupported RNN type. Choose from 'rnn', 'lstm', 'gru'.")
            
        self.fc = nn.Linear(hidden_dim, output_dim)
        self.to(self.device) # 將模型移動到指定設備

    def forward(self, x, h_init_tuple=None):
        x = x.to(self.device) # 將輸入數據移動到設備
        
        # 如果沒有提供初始隱藏狀態,則初始化為零
        if h_init_tuple is None:
            h0 = torch.zeros(self.num_layers, x.size(1), self.hidden_dim).to(self.device)
            if self.rnn_type == 'lstm':
                c0 = torch.zeros(self.num_layers, x.size(1), self.hidden_dim).to(self.device)
                h_init_tuple = (h0, c0)
            else: # RNN or GRU
                h_init_tuple = h0
        else: # 如果提供了,確保它們在正確的設備上
            if self.rnn_type == 'lstm':
                 h_init_tuple = (h_init_tuple[0].to(self.device), h_init_tuple[1].to(self.device))
            else:
                 h_init_tuple = h_init_tuple.to(self.device)

        # RNN 層的前向傳播
        if self.rnn_type == 'lstm':
            # 對於 LSTM,rnn 返回 (所有時間步的輸出, (最後時間步的隱藏狀態, 最後時間步的細胞狀態))
            rnn_all_outputs, final_hidden_states = self.rnn(x, h_init_tuple) 
        else: # RNN or GRU
            # 對於 RNN/GRU,rnn 返回 (所有時間步的輸出, 最後時間步的隱藏狀態)
            rnn_all_outputs, final_hidden_states = self.rnn(x, h_init_tuple)
        
        # rnn_all_outputs shape: (seq_len, batch_size, hidden_size)
        # 我們需要對序列中的每個時間步進行預測
        # 將 RNN 輸出調整形狀以傳遞到全連接層
        # view(-1, self.hidden_dim) 會將其變為 (seq_len * batch_size, hidden_size)
        predictions_logits = self.fc(rnn_all_outputs.view(-1, self.hidden_dim)) 
        # predictions_logits shape: (seq_len * batch_size, output_size)
        
        return predictions_logits, final_hidden_states

# 選擇模型類型: 'rnn', 'lstm', or 'gru'
# LSTM 和 GRU 通常在捕捉序列依賴性方面表現更好
model = CharRNNPredictor(input_size, hidden_size, output_size, num_layers=num_layers, rnn_type='lstm')
device = model.device # 從模型中獲取設備訊息

# 3. 定義損失函數和優化器
criterion = nn.CrossEntropyLoss() # 適用於分類問題
optimizer = optim.Adam(model.parameters(), lr=learning_rate)

# 4. 訓練循環
print(f"🚀 Training with {model.rnn_type.upper()} on {device}...")
for epoch in range(num_epochs):
    model.train() # 將模型設置為訓練模式
    optimizer.zero_grad() # 清除之前的梯度
    
    # 前向傳播
    # input_one_hot shape: (seq_len, batch_size=1, input_size)
    # target_seq_idx shape: (seq_len)
    outputs_logits, _ = model(input_one_hot) # outputs_logits shape: (seq_len * batch_size, output_size)
    
    # 計算損失
    # criterion 的輸入 logits 應為 (N, C),目標應為 (N)
    # N = seq_len * batch_size, C = num_classes (output_size)
    # target_seq_idx.view(-1) 將目標展平為 (seq_len * batch_size)
    loss = criterion(outputs_logits, target_seq_idx.view(-1).to(device)) 
    
    # 反向傳播和優化
    loss.backward()
    optimizer.step()
    
    if (epoch+1) % 50 == 0: # 每 50 個 epoch 打印一次損失
        print(f'Epoch [{epoch+1}/{num_epochs}], Loss: {loss.item():.4f}')

# 5. 測試/推斷
model.eval() # 將模型設置為評估模式
with torch.no_grad(): # 在評估模式下不需要計算梯度
    test_input_str = "hel"
    print(f"\n🧪 Testing with input: '{test_input_str}'")
    
    # 準備測試輸入
    test_input_idx = torch.tensor([char_to_idx[c] for c in test_input_str])
    test_input_one_hot = nn.functional.one_hot(test_input_idx, num_classes=input_size).float().unsqueeze(1).to(device)
    
    predictions_logits, _ = model(test_input_one_hot)
    # predictions_logits shape: (seq_len=3, output_size=4) because batch_size=1 was squeezed by view
    
    # 獲取每個時間步預測概率最高的字符
    predicted_indices = torch.argmax(predictions_logits, dim=1) # shape: (seq_len=3)
    predicted_chars = "".join([idx_to_char[idx.item()] for idx in predicted_indices])
    
    print(f"Input sequence: '{test_input_str}'")
    print(f"Predicted output sequence: '{predicted_chars}' (Target was: '{target_str}')")

    # 生成式推斷:給定一個起始字符,逐個生成後續字符
    print("\n🖋️ Generative inference (predicting next char iteratively):")
    start_char = 'h'
    num_generate = 3 # 生成字符的數量
    generated_sequence = start_char
    
    # 初始化隱藏狀態 (對於 LSTM,是 (h, c) 元組)
    current_hidden_state = None # 模型內部會處理初始化
    if model.rnn_type == 'lstm':
        h_init = torch.zeros(num_layers, 1, hidden_size).to(device)
        c_init = torch.zeros(num_layers, 1, hidden_size).to(device)
        current_hidden_state = (h_init, c_init)
    else: # RNN or GRU
        current_hidden_state = torch.zeros(num_layers, 1, hidden_size).to(device)

    current_char_idx = torch.tensor([char_to_idx[start_char]]).to(device)

    for _ in range(num_generate):
        # 準備當前字符的 one-hot 輸入: (seq_len=1, batch_size=1, input_size)
        input_char_one_hot = nn.functional.one_hot(current_char_idx, num_classes=input_size).float().unsqueeze(0).to(device)
        
        # 模型預測下一個字符
        # output_logit shape: (1, output_size)
        # next_hidden_state is the updated hidden state
        output_logit, current_hidden_state = model(input_char_one_hot, current_hidden_state) 
        
        # 獲取概率最高的字符索引
        predicted_idx = torch.argmax(output_logit, dim=1) # shape: (1)
        
        # 更新當前字符索引為預測的字符索引,用於下一次迭代
        current_char_idx = predicted_idx
        
        # 將預測的字符添加到生成序列中
        generated_sequence += idx_to_char[predicted_idx.item()]
        
    print(f"Starting with '{start_char}', generated sequence: '{generated_sequence}'")

這個字符級別的 RNN 範例雖然簡單,但它涵蓋了數據準備、模型定義、訓練循環和兩種推斷方式(序列到序列預測和生成式預測)的核心步驟。在實際應用中,你會處理更大的詞彙表、更長的序列,並可能使用詞嵌入 (word embeddings) 而非 one-hot 編碼,以及更複雜的模型架構。

🧩 序列到序列 (Seq2Seq) 模型:應對更複雜的序列任務

在掌握了 RNN 的基礎後,我們可以進一步了解一種更強大的架構:序列到序列 (Seq2Seq) 模型。基礎的 RNN、LSTM 和 GRU 通常用於輸入序列和輸出序列長度相同,或者輸出是一個固定大小的向量(例如情感分類)的任務。然而,許多現實世界的任務,如機器翻譯(例如,將英文句子翻譯成中文句子)或文本摘要,輸入序列和輸出序列的長度往往不同,且長度不固定。這時,Seq2Seq 模型就派上用場了。

Seq2Seq 要解決的問題

Seq2Seq 模型的核心目標是學習一個從輸入序列到輸出序列的映射,即使這兩個序列的長度不同。

  • 機器翻譯:輸入 "Hello world" (2個詞),輸出 "你好世界" (4個字)。
  • 對話系統:輸入用戶問題,輸出系統回答。
  • 文本摘要:輸入長篇文章,輸出簡短摘要。

核心架構:編碼器-解碼器 (Encoder-Decoder)

Seq2Seq 模型通常由兩個主要部分組成:一個編碼器 (Encoder) 和一個解碼器 (Decoder)。這兩個部分通常都是 RNN(可以是 Vanilla RNN, LSTM 或 GRU)。

  1. 編碼器 (Encoder)

    • 角色:編碼器的任務是處理整個輸入序列,並將其「壓縮」成一個固定長度的上下文向量 (context vector)。這個向量旨在捕捉輸入序列的全部語義信息。
    • 運作方式:編碼器逐個讀取輸入序列的元素(例如,句子中的每個詞)。在處理完所有輸入後,編碼器最後的隱藏狀態就被用作上下文向量。
  2. 解碼器 (Decoder)

    • 角色:解碼器的任務是接收編碼器生成的上下文向量,並逐個生成輸出序列的元素。
    • 運作方式:解碼器的初始隱藏狀態通常用編碼器的上下文向量來初始化。
      • 解碼器在第一個時間步接收一個特殊的起始符號 <SOS> (Start Of Sequence) 作為輸入。
      • 然後,它預測輸出序列的第一個元素。
      • 在下一個時間步,前一個時間步預測的輸出(或真實的目標輸出,見下文「教師強制」)將作為當前時間步的輸入,以此類推,直到生成一個特殊的結束符號 <EOS> (End Of Sequence) 或達到預設的最大輸出長度。

Seq2Seq structure (https://en.wikipedia.org/wiki/File:Seq2seq_with_RNN_and_attention_mechanism.gif)

Seq2Seq 結構圖(來源:wikipedia

訓練技巧:教師強制 (Teacher Forcing)

在訓練解碼器時,如果我們總是使用模型前一個時間步的預測作為當前時間步的輸入,那麼一旦模型在早期產生錯誤,這個錯誤可能會被放大,導致後續的預測越來越差,訓練過程變得不穩定且緩慢。

為了解決這個問題,教師強制 (Teacher Forcing) 被廣泛使用。在教師強制下,訓練解碼器時,無論前一個時間步模型的預測是什麼,當前時間步的輸入總是使用真實的目標序列 (ground truth) 中的元素。

  • 優點:使得模型訓練更穩定,收斂更快,因為模型在每個時間步都能接收到正確的輸入。
  • 缺點:訓練時和推斷 (inference) 時的行為不一致(推斷時沒有真實目標序列可用)。這可能導致模型在推斷時表現下降。通常會配合一些策略(如 scheduled sampling)來緩解這個問題。

Seq2Seq 概念性偽代碼

以下是一個高度概念化的 Seq2Seq 模型結構,旨在幫助理解其核心組件和流程,而非一個具體的實現。

// --- 概念性偽代碼:Seq2Seq 模型 ---

// --- 編碼器 (Encoder) ---
DEFINE Encoder:
    INPUT: source_sequence (一系列來源語言的詞語)
    // 內部通常包含 Embedding 層和 RNN 層 (LSTM/GRU)
    OUTPUT: context_vector (代表整個來源序列的語義)

// --- 解碼器 (Decoder) ---
DEFINE Decoder:
    INPUT: 
        current_target_token (當前要處理的目標語言詞語)
        previous_decoder_state (解碼器上一步的狀態)
        encoder_context_vector (來自編碼器的上下文信息)
    // 內部通常包含 Embedding 層, RNN 層 (LSTM/GRU), 和一個輸出線性層
    OUTPUT: 
        predicted_token_probabilities (對目標詞彙表中每個詞的預測概率)
        next_decoder_state (更新後的解碼器狀態)

// --- Seq2Seq 主模型 ---
DEFINE Seq2Seq_Model:
    INPUT: 
        source_sequence
        target_sequence (在訓練時提供)
        teacher_forcing_probability 
    INTERNAL_STATE:
        encoder_instance = CREATE Encoder()
        decoder_instance = CREATE Decoder()
        all_predicted_outputs = EMPTY_LIST

    // 1. 編碼階段
    context = encoder_instance.process(source_sequence)

    // 2. 解碼階段
    decoder_current_state = INITIALIZE_DECODER_STATE with context
    current_token_for_decoder = GET <SOS>_token // 特殊的序列開始符號

    LOOP for each position t in target_sequence_length:
        predicted_probs, next_state = decoder_instance.process(
            current_token_for_decoder, 
            decoder_current_state,
            context // 注意:context 也可能以其他方式影響解碼
        )
        
        ADD predicted_probs TO all_predicted_outputs
        decoder_current_state = next_state

        IF training AND random_number < teacher_forcing_probability:
            current_token_for_decoder = GET target_sequence[t] // 教師強制
        ELSE:
            current_token_for_decoder = GET most_probable_token FROM predicted_probs // 使用模型預測
        
        IF current_token_for_decoder IS <EOS>_token: BREAK_LOOP // 序列結束

    OUTPUT: all_predicted_outputs

這個偽代碼更側重於流程和組件的角色,避免了具體的實現細節。它展示了編碼器如何處理輸入,解碼器如何基於編碼器的上下文和自身先前的輸出來逐步生成目標序列,以及教師強制是如何在訓練中起作用的。

Seq2Seq 的局限性與注意力機制 (Attention Mechanism)

基本的 Seq2Seq 模型的一個主要瓶頸是它試圖將整個輸入序列的「意義」壓縮到一個固定長度的上下文向量中。對於非常長的輸入序列,這個上下文向量可能無法有效地捕捉所有重要資訊,導致信息丟失。

為了解決這個問題,注意力機制 (Attention Mechanism) 被引入到 Seq2Seq 模型中。注意力機制允許解碼器在生成輸出序列的每個步驟時,動態地「關注」輸入序列的不同部分,而不是僅僅依賴於單一的固定上下文向量。這極大地提高了模型在長序列任務(如機器翻譯)上的性能。

這個簡化的圖示展示了注意力機制如何連接編碼器的所有隱藏狀態和解碼器的當前狀態,以生成一個動態的上下文向量,用於指導當前時間步的預測。

🚀 從 RNN 到 Transformer:序列處理的演進

儘管帶有注意力機制的 Seq2Seq 模型取得了巨大成功,但它們仍然依賴於 RNN 的循序計算特性。RNN 必須逐個時間步處理序列,這限制了訓練過程中的並行化程度,使得處理極長序列時效率較低。

為了解決這個問題,Vaswani 等人在 2017 年的論文《Attention Is All You Need》中提出了 Transformer 模型。Transformer 完全拋棄了 RNN 的循環結構,而是完全基於自注意力機制 (Self-Attention)跨注意力機制 (Cross-Attention)

由於 Transformer 不再需要循序計算,它可以對序列中的所有詞進行高度並行化的處理,從而極大地提高了訓練效率,並且在多種 NLP 任務上取得了突破性的成果,成為了當今自然語言處理領域的主流架構 (例如 BERT, GPT 等模型的基礎)。

對 Transformer 的詳細討論超出了本文的範圍,但理解 RNN、Seq2Seq 和注意力機制是學習 Transformer 的重要前置知識。

✨ 總結與展望

循環神經網絡及其變體 (LSTM, GRU) 是處理序列數據的基石。它們通過隱藏狀態在時間步之間傳遞信息,從而捕捉序列中的依賴關係。然而,它們的循序處理特性也帶來了挑戰,如梯度消失/爆炸和處理長序列的效率問題。

Seq2Seq 模型通過編碼器-解碼器架構擴展了 RNN 的能力,使其能夠處理輸入和輸出序列長度不同的任務。注意力機制的引入進一步提升了這些模型的性能。

最終,對 RNN 循序性的突破導致了 Transformer 架構的誕生。Transformer 完全依賴注意力機制,實現了高度並行化,並在自然語言處理領域取得了革命性的進展。

儘管 Transformer 已成為主流,但理解 RNN、LSTM、GRU 和 Seq2Seq 的原理對於深入學習序列建模仍然至關重要。它們不僅是理解更高級模型的基礎,而且在某些資源受限或對延遲有嚴格要求的場景下,較輕量級的 RNN 模型仍然有其用武之地。

希望這篇文章能為你打下堅實的基礎,助你進一步探索序列數據處理的奇妙世界!

📚 參考資料 (References)

KF Software House