在深度學習模型的演進過程中,Transformer架構無疑是近年來最重要的突破之一。作為現代大模型語言的基礎,理解Transformer的核心元件對於掌握現代NLP技術至關重要。在這篇文章中,我將深入解析Transformer中最核心的部分 - 注意力機制,並透過程式碼實作來展示其運作原理。
注意力機制的計算流程
注意力機制的計算過程可分為四個關鍵步驟:
- 生成查詢(Query)、鍵(Key)和值(Value)向量:將每個輸入的詞嵌入轉換成這三種不同用途的向量
- 計算注意力分數:透過查詢和鍵之間的相似度計算來確定不同詞之間的關聯強度
- 應用softmax獲得權重:將分數轉換為機率分佈,確定每個值向量的重要性
- 更新詞嵌入:將注意力權重與值向量相乘並求和,得到融合了上下文訊息的新表示
對於每個位置i的詞嵌入,更新後的表示可以表示為:x'_i = ∑_j w_ji v_j
,其中w_ji
是注意力權重,v_j
是值向量。
使用BertViz視覺化注意力權重
理解注意力權重的計算方式可能有些抽象,使用視覺化工具能夠極大地幫助我們理解這個過程。BertViz是一個專為Transformer模型設計的視覺化工具,能夠直觀地展示查詢和鍵向量如何組合產生最終的注意力權重。
以下是使用BertViz視覺化BERT模型注意力層的範例:
from transformers import AutoTokenizer
from bertviz.transformers_neuron_view import BertModel
from bertviz.neuron_view import show
model_ckpt = "bert-base-uncased"
tokenizer = AutoTokenizer.from_pretrained(model_ckpt)
model = BertModel.from_pretrained(model_ckpt)
text = "time flies like an arrow"
show(model, "bert", tokenizer, text, display_mode="light", layer=0, head=8)
這段程式碼載入了預訓練的BERT模型和對應的分詞器,然後使用BertViz的show
函式來視覺化特定層和注意力頭的運作。在這個例子中,我們查看了第0層的第8個注意力頭對於"time flies like an arrow"這句話的處理。視覺化結果會顯示查詢和鍵向量的值,以及它們之間的連線強度。從視覺化結果可以看出,“flies"的查詢向量與"arrow"的鍵向量有最強的重疊,這表明模型識別出了這兩個詞之間的語義關聯。
理解查詢、鍵和值向量的概念
初次接觸查詢(Query)、鍵(Key)和值(Value)向量的概念時,可能會感到有些困惑。這些名稱源自資訊檢索系統,但我們可以用一個簡單的類別比來理解它們。
想像你在超市購買晚餐所需的食材。你手中有一張購物清單(查詢),當你瀏覽貨架上的標籤(鍵)時,你會檢查它們是否與清單上的食材比對(相似度函式)。如果找到比對項,你就會從貨架上取下商品(值)。
在這個類別比中,你只會為每個比對的標籤取一件商品。而自注意力機制則是這個過程的更抽象和"平滑"版本:超市中的每個標籤都會在一定程度上比對你的食材,程度取決於每個鍵與查詢的比對程度。所以如果你的清單上寫著"一打雞蛋”,你可能最終會拿到10個雞蛋、一個煎蛋捲和一個雞翅。
深入實作:scaled dot-product attention
為了更深入理解注意力機制,讓我們實作圖3-4所示的縮放點積注意力(scaled dot-product attention)。
在實作之前,我們先來瞭解一下PyTorch和TensorFlow中用於實作Transformer的核心元件對應關係:
PyTorch | TensorFlow (Keras) | 功能 |
---|---|---|
nn.Linear | keras.layers.Dense | 全連線神經網路層 |
nn.Module | keras.layers.Layer | 模型的基本構建塊 |
nn.Dropout | keras.layers.Dropout | Dropout層 |
nn.LayerNorm | keras.layers.LayerNormalization | 層正規化 |
nn.Embedding | keras.layers.Embedding | 嵌入層 |
nn.GELU | keras.activations.gelu | 高斯誤差線性單元啟用函式 |
nn.bmm | tf.matmul | 批次矩陣乘法 |
model.forward | model.call | 模型前向傳播 |
實作步驟
首先,我們需要對文字進行分詞處理,並取得輸入ID:
inputs = tokenizer(text, return_tensors="pt", add_special_tokens=False)
inputs.input_ids
# 輸出: tensor([[ 2051, 10029, 2066, 2019, 8612]])
這段程式碼使用BERT的分詞器將文字轉換為模型可以處理的ID序列。每個詞元(token)都被對映到分詞器詞彙表中的唯一ID。我們設定add_special_tokens=False
來排除[CLS]和[SEP]等特殊標記,使範例更簡單。輸出顯示句子"time flies like an arrow"被轉換為5個數字ID。
接下來,我們需要建立密集詞嵌入。這裡的"密集"指的是嵌入向量中的每個元素都可能包含非零值,與我們之前看到的稀疏的獨熱編碼(one-hot encoding)不同:
from torch import nn
from transformers import AutoConfig
config = AutoConfig.from_pretrained(model_ckpt)
token_emb = nn.Embedding(config.vocab_size, config.hidden_size)
token_emb
# 輸出: Embedding(30522, 768)
我們使用AutoConfig
類別載入與BERT模型相關的設定檔案,該檔案指定了各種超引數。然後建立一個嵌入層作為查詢表,將每個輸入ID對映到768維的向量空間。這個嵌入層包含30,522個向量,對應BERT詞彙表的大小。這些初始嵌入是上下文無關的,意味著同形異義詞(拼寫相同但含義不同的詞)此時具有相同的表示。後續的注意力層會混合這些嵌入,使每個詞的表示融入上下文訊息。
現在我們可以生成詞嵌入:
inputs_embeds = token_emb(inputs.input_ids)
inputs_embeds.size()
# 輸出: torch.Size([1, 5, 768])
這行程式碼將輸入ID轉換為嵌入向量。結果是一個形狀為[batch_size, seq_len, hidden_dim]的張量,其中batch_size=1(一個句子),seq_len=5(五個詞元),hidden_dim=768(每個詞元的嵌入維度)。
接下來,我們建立查詢、鍵和值向量,並使用點積作為相似度函式運算注意力分數:
import torch
from math import sqrt
query = key = value = inputs_embeds
dim_k = key.size(-1)
scores = torch.bmm(query, key.transpose(1,2)) / sqrt(dim_k)
scores.size()
# 輸出: torch.Size([1, 5, 5])
為了簡化範例,我們暫時將查詢、鍵和值向量設為相同的嵌入向量。在完整的Transformer中,這些向量是透過對嵌入應用不同的權重矩陣W^Q
、W^K
和W^V
生成的。
我們使用torch.bmm()
函式執行批次矩陣乘法,計算每個查詢向量與所有鍵向量的點積,得到注意力分數矩陣。這個矩陣的形狀是[1, 5, 5],表示一個批次中5個詞元之間的所有可能注意力分數。
在縮放點積注意力中,點積結果會除以嵌入向量大小的平方根(這裡是√768)。這種縮放操作防止了在訓練過程中產生過多大數值,這些大數值可能導致我們接下來應用的softmax函式飽和。
torch.bmm()
函式非常適合這種計算,因為它可以同時處理批次中的多個序列,對每個序列獨立執行矩陣乘法。
接下來應用softmax函式:
import torch.nn.functional as F
weights = F.softmax(scores, dim=-1)
weights.sum(dim=-1)
# 輸出: tensor([[1., 1., 1., 1., 1.]], grad_fn=<SumBackward1>)
我們對注意力分數應用softmax函式,將分數轉換為機率分佈。dim=-1
引數指定在最後一個維度上應用softmax,確保每個詞元的注意力權重總和為1,如輸出所示。這些權重決定了每個詞元在計算上下文表示時對其他詞元的關注程度。
最後,我們將注意力權重與值向量相乘:
attn_outputs = torch.bmm(weights, value)
attn_outputs.shape
# 輸出: torch.Size([1, 5, 768])
這是注意力機制的最後一步,我們將注意力權重與值向量相乘,產生融合了上下文訊息的新表示。結果是一個形狀為[1, 5, 768]的張量,與原始嵌入形狀相同,但現在每個詞元的表示都包含了來自其他詞元的訊息,權重由注意力分數決定。
這就是自注意力機制的核心實作!整個過程只涉及兩次矩陣乘法和一次softmax操作,但這種簡單而強大的機制是Transformer模型卓越效能的關鍵所在。
注意力機制的深層含義
自注意力機制之所以強大,在於它能讓模型動態地決定每個詞元應該關注序列中的哪些其他詞元。這種能力使Transformer能夠捕捉長距離依賴關係,這是RNN和CNN等傳統架構的弱點。
在實際應用中,Transformer使用多頭注意力(Multi-Head Attention),允許模型同時從不同的表示子網路關注不同的訊息模式。每個注意力頭可以專注於不同型別的關係,例如語法結構、語義相關性或共指關係。
這種設計也是大模型語言能夠理解複雜上下文並生成連貫文字的關鍵因素。透過堆積積疊多層注意力機制,模型可以逐步建立從詞元級別到句子和段落級別的理解。
從理論到實踐:注意力機制的應用
理解注意力機制的原理後,我們可以更好地應用預訓練的Transformer模型,如BERT、GPT等。在微調這些模型時,瞭解它們如何透過注意力機制處理訊息可以幫助我們設計更有效的任務適配策略。
例如,對於需要理解實體間關係的任務,我們可能希望增強特定注意力頭的影響;而對於需要全域訊息的任務,我們可能需要確保模型能夠有效利用[CLS]標記的表示。
注意力權重也提供了模型決策過程的可解釋性。透過視覺化注意力模式,我們可以洞察模型如何"理解"文字,哪些詞元被認為是相關的,以及模型可能在哪些方面存在偏見或盲點。
Transformer架構的注意力機制徹底改變了自然語言處理領域,使我們能夠構建更強大、更靈活的模型。透過深入理解其運作原理,我們不僅能更有效地應用這些模型,還能推動下一代AI技術的發展。
在實作過程中,我發現注意力機制的簡潔設計背後隱藏著深刻的數學洞見。這種將複雜語言關係轉化為向量空間運算的方法,是深度學習在NLP領域取得突破的關鍵。透過親手實作這些機制,我們能夠更深入地理解模型為何能夠如此有效地處理人類語言的複雜性。
解構 Transformer 的核心:自注意力機制
Transformer 模型徹底改變了自然語言處理領域,而自注意力機制正是其成功的核心。在實作大模型語言時,理解這些機制的內部運作原理至關重要。本文將探討 Transformer 架構中的注意力機制實作細節,並透過 PyTorch 程式碼範例展示如何實作這些關鍵元件。
縮放點積注意力:注意力的基礎
首先,讓我們將之前討論的注意力計算步驟封裝到一個函式中,以便後續使用:
def scaled_dot_product_attention(query, key, value):
dim_k = query.size(-1)
scores = torch.bmm(query, key.transpose(1, 2)) / sqrt(dim_k)
weights = F.softmax(scores, dim=-1)
return torch.bmm(weights, value)
這個函式實作了縮放點積注意力的核心運算。讓我們逐行分析:
dim_k = query.size(-1)
- 取得查詢向量的維度大小,這將用於縮放操作scores = torch.bmm(query, key.transpose(1, 2)) / sqrt(dim_k)
- 計算查詢和鍵之間的點積,並按維度的平方根進行縮放。torch.bmm
是批次矩陣乘法,處理多個序列weights = F.softmax(scores, dim=-1)
- 對分數應用 softmax 函式,轉換為機率權重return torch.bmm(weights, value)
- 使用這些權重對值向量進行加權求和
縮放操作(除以 sqrt(dim_k)
)是非常關鍵的,它可以防止在高維空間中點積值變得過大,從而導致 softmax 函式梯度消失。
解決自我關注的問題
在自注意力機制中,當查詢和鍵向量相同時,會產生一個有趣的現象:詞彙與自身的點積總是最大的。這意味著模型可能過度關注詞彙本身,而忽略了上下文中的其他詞彙。
例如,在理解「flies」一詞的含義時,從「time」和「arrow」等相關詞取得的訊息可能比另一個「flies」更有價值。這就是為什麼我們需要為每個詞彙建立不同的查詢、鍵和值向量,透過三個獨立的線性投影來實作這一點。
多頭注意力機制:注意力的多維視角
在實際應用中,單一的注意力機制往往不足以捕捉序列中的所有語義關係。多頭注意力透過平行執行多個注意力「頭」來解決這個問題,每個頭都可以專注於不同的語義方面。
這種設計與卷積神經網路中的濾波器有相似之處 - 一個濾波器可能負責檢測臉部,而另一個可能尋找車輪。在 Transformer 中,一個注意力頭可能專注於主謂關係,而另一個可能尋找鄰近的形容詞。
讓我們首先實作單個注意力頭:
class AttentionHead(nn.Module):
def __init__(self, embed_dim, head_dim):
super().__init__()
self.q = nn.Linear(embed_dim, head_dim)
self.k = nn.Linear(embed_dim, head_dim)
self.v = nn.Linear(embed_dim, head_dim)
def forward(self, hidden_state):
attn_outputs = scaled_dot_product_attention(
self.q(hidden_state), self.k(hidden_state), self.v(hidden_state))
return attn_outputs
這個類別定義了單個注意力頭的實作:
- 初始化三個獨立的線性層(
self.q
,self.k
,self.v
),用於將輸入嵌入投影到查詢、鍵和值空間 - 在前向傳遞中,將隱藏狀態透過這三個投影層,然後將結果傳遞給縮放點積注意力函式
- 每個投影將形狀為 [batch_size, seq_len, embed_dim] 的張量轉換為 [batch_size, seq_len, head_dim]
head_dim
通常是 embed_dim
的一部分。例如,BERT 模型有 12 個注意力頭,所以每個頭的維度是 768/12 = 64。這種設計允許每個頭專注於嵌入的不同子網路。
現在,我們可以實作完整的多頭注意力層:
class MultiHeadAttention(nn.Module):
def __init__(self, config):
super().__init__()
embed_dim = config.hidden_size
num_heads = config.num_attention_heads
head_dim = embed_dim // num_heads
self.heads = nn.ModuleList(
[AttentionHead(embed_dim, head_dim) for _ in range(num_heads)]
)
self.output_linear = nn.Linear(embed_dim, embed_dim)
def forward(self, hidden_state):
x = torch.cat([h(hidden_state) for h in self.heads], dim=-1)
x = self.output_linear(x)
return x
多頭注意力層的實作涉及以下步驟:
- 根據設定初始化多個注意力頭,每個頭的維度為
head_dim = embed_dim // num_heads
- 在前向傳遞中,將每個注意力頭的輸出在最後一個維度上連線起來
- 將連線後的輸出透過一個最終的線性層,以產生適合後續前饋網路的輸出張量
這種設計允許模型同時關注序列的多個方面,大增強了其表示能力。連線多個頭的輸出並透過最後的線性層,可以將這些不同的「視角」整合成一個統一的表示。
注意力在實際中的表現
讓我們透過一個實際例子來觀察注意力機制如何處理歧義。考慮「flies」一詞在兩個不同上下文中的使用:
- “time flies like an arrow”(時間飛逝如箭)- 這裡的「flies」是動詞
- “fruit flies like a banana”(果蠅喜歡香蕉)- 這裡的「flies」是名詞
在第一個句子中,注意力機制會將「flies」與「arrow」等詞關聯起來,而在第二個句子中,則會關注「fruit」和「banana」。這種上下文感知能力使模型能夠區分同一個詞的不同用法,這是傳統模型難以實作的。
前饋網路層:增強表示能力
注意力機制之後,Transformer 的另一個關鍵元件是前饋網路層。這是一個簡單的兩層全連線神經網路,但有一個特點:它獨立處理每個位置的嵌入,而不是將整個序列視為單一向量。因此,這一層通常被稱為「位置式前饋層」。
根據經驗法則,第一層的隱藏大小通常是嵌入大小的四倍,與大多使用 GELU 啟用函式。這一層被認為是模型中容量和記憶功能的主要所在,也是擴充套件模型時最常調整的部分。
class FeedForward(nn.Module):
def __init__(self, config):
super().__init__()
self.linear_1 = nn.Linear(config.hidden_size, config.intermediate_size)
self.linear_2 = nn.Linear(config.intermediate_size, config.hidden_size)
self.gelu = nn.GELU()
self.dropout = nn.Dropout(config.hidden_dropout_prob)
def forward(self, x):
x = self.linear_1(x)
x = self.gelu(x)
x = self.linear_2(x)
x = self.dropout(x)
return x
這個前饋網路層的實作包含:
- 兩個線性層:第一層將隱藏狀態投影到一個更大的中間空間,第二層將其投影回原始大小
- GELU 啟用函式:比 ReLU 更平滑,在 Transformer 模型中表現更好
- Dropout 層:用於正則化,防止過擬合
值得注意的是,當我們將形狀為 (batch_size, seq_len, hidden_dim) 的張量傳遞給線性層時,它會獨立地應用於批次和序列維度中的每個元素,這正是我們所需要的 - 每個位置的嵌入都獨立透過相同的前饋網路。
Transformer 編碼器的完整架構
將以上元件組合起來,我們就得到了 Transformer 編碼器的核心架構。每個編碼器層包含:
- 多頭自注意力機制
- 位置式前饋網路
- 殘差連線和層歸一化
這種設計允許訊息在序列中自由流動,同時保持位置的獨立性,這對於處理自然語言等序列資料非常有效。
注意力機制的實際應用
注意力機制的美妙之處在於它的靈活性和可解釋性。透過視覺化注意力權重,我們可以直觀地理解模型如何解釋文字。例如,在處理代詞解析或實體關係時,注意力權重往往會顯示出有意義的模式。
在實際應用中,我常發現注意力機制能夠捕捉到人類語言學家也會注意的語言現象,如主謂一致、修飾關係等。這種能力使 Transformer 模型在各種 NLP 任務中表現出色,從機器翻譯到問答系統。
Transformer 架構的擴充套件與應用
理解了 Transformer 的核心元件後,我們可以看到這種架構的靈活性和可擴充套件性。從原始的 Transformer 到 BERT、GPT、T5 等模型,都是根據相同的基本原理,但在細節上有所調整。
例如,BERT 使用雙向編碼器,而 GPT 使用單向解碼器;T5 將所有 NLP 任務統一為文字到文字的轉換。這些變體展示了 Transformer 架構的強大適應性。
隨著模型規模的增加,我們也看到了一些有趣的現象,如湧現能力(emergent abilities)- 當模型達到一定規模時,突然展現出之前未見的能力。這表明,隨著我們繼續擴充套件 Transformer 架構,可能會有更多令人驚奇的發現等待著我們。
在實作自己的 Transformer 模型時,理解這些核心元件的工作原理至關重要。透過調整注意力頭的數量、前饋網路的大小、層數等超引數,我們可以為特定任務定製模型架構。
自注意力機制的引入徹底改變了深度學習在序列處理上的能力。透過允許模型關注輸入序列中的任何部分,無論距離多遠,Transformer 解決了 RNN 和 CNN 架構中的長距離依賴問題。這一突破使得更複雜的語言理解和生成為可能,推動了近年來 NLP 領域的快速發展。
透過深入理解這些機制的內部運作原理,我們不僅能更好地應用現有模型,還能為未來的創新奠定基礎。隨著研究的不斷深入,我相信 Transformer 架構還有許多潛力有待挖掘。
Layer Normalization與Skip Connection的關鍵決策
在實作Transformer架構時,最後一個重要決策是Layer Normalization與Skip Connection的放置位置。這個看似微小的設計選擇,實際上對模型的訓練穩定性和效能有著顯著影響。
Layer Normalization的兩種主流放置方式
在實作Transformer編碼器或解碼器層時,Layer Normalization的放置有兩種主要選擇,每種選擇都有其獨特的特性和應用場景:
後置層標準化 (Post Layer Normalization)
這是最初Transformer論文中採用的方式,Layer Normalization被放置在Skip Connection之間。雖然這是原始設計,但實際應用中存在一個明顯缺點:
Input → Self-Attention → Add → LayerNorm → FeedForward → Add → LayerNorm → Output
這種設定在從頭開始訓練時相當棘手,因為梯度容易發散。為瞭解決這個問題,研究人員引入了「學習率預熱」(learning rate warm-up)的概念,即在訓練初期逐漸將學習率從小值增加到設定的最大值。
前置層標準化 (Pre Layer Normalization)
現在文獻中最常見的設定是前置層標準化,它將Layer Normalization放在Skip Connection的範圍內:
Input → LayerNorm → Self-Attention → Add → LayerNorm → FeedForward → Add → Output
這種方式在訓練過程中表現更加穩定,通常不需要特殊的學習率預熱策略。由於其穩定性優勢,這已成為許多現代Transformer實作的首選方案。
在實作中,我決定採用前置層標準化方式,因為它能提供更穩定的訓練過程,特別是在資源有限或需要快速迭代的場景下。
實作Transformer編碼器層
根據前置層標準化的設計,讓我們實作一個完整的Transformer編碼器層:
class TransformerEncoderLayer(nn.Module):
def __init__(self, config):
super().__init__()
self.layer_norm_1 = nn.LayerNorm(config.hidden_size)
self.layer_norm_2 = nn.LayerNorm(config.hidden_size)
self.attention = MultiHeadAttention(config)
self.feed_forward = FeedForward(config)
def forward(self, x):
# 先應用層標準化,然後將結果複製到query、key、value
hidden_state = self.layer_norm_1(x)
# 應用注意力機制,並加上殘差連線
x = x + self.attention(hidden_state)
# 應用前饋網路層,同樣加上殘差連線
x = x + self.feed_forward(self.layer_norm_2(x))
return x
這個編碼器層實作了前置層標準化策略。首先對輸入進行標準化,然後將結果傳給注意力機制。注意力的輸出與原始輸入相加,形成第一個殘差連線。之後,對這個結果再次進行層標準化,傳入前饋網路,最後將前饋網路的輸出與其輸入相加,形成第二個殘差連線。
這種設計使得梯度能夠更容易地流過整個網路,減少了訓練過程中的不穩定性。殘差連線(Skip Connection)允許訊息直接跳過某些層,有效緩解了深度網路中的梯度消失問題。
位置訊息的關鍵:位置編碼
雖然我們已經實作了Transformer編碼器層,但還存在一個重要的限制:目前的架構完全忽略了token的位置訊息。由於多頭注意力機制本質上是一種加權和運算,它對輸入序列中token的順序不敏感。
換句話說,如果我們將輸入序列中的token順序打亂,目前的模型會產生完全相同的輸出。這在處理自然語言時是個嚴重問題,因為「貓追狗」和「狗追貓」雖然包含相同的token,但意思完全不同。
位置編碼的實作方式
位置編碼的核心思想是:將表示位置的特定模式融入token嵌入中。如果每個位置都有其特徵性的模式,那麼Transformer的各層就能學會將位置訊息納入其轉換過程。
有幾種實作位置編碼的方法:
可學習的位置編碼:當預訓練資料集足夠大時,這是最常用的方法。它的工作原理與token嵌入相似,但使用位置索引而非token ID作為輸入。
絕對位置表示:使用由正弦和餘弦訊號組成的靜態模式來編碼token位置。當沒有大量資料可用時,這種方法效果特別好。
相對位置表示:這種方法編碼的是token之間的相對位置,而非絕對位置。這需要修改注意力機制本身,加入考慮token間相對位置的額外項。DeBERTa等模型採用這種表示。
旋轉位置編碼(RoPE):結合了絕對和相對位置表示的思想,在許多工上取得出色結果。GPT-Neo是使用旋轉位置編碼的模型範例。
實作可學習的位置編碼
讓我們實作一個結合token嵌入和位置嵌入的嵌入層:
class Embeddings(nn.Module):
def __init__(self, config):
super().__init__()
self.token_embeddings = nn.Embedding(config.vocab_size,
config.hidden_size)
self.position_embeddings = nn.Embedding(config.max_position_embeddings,
config.hidden_size)
self.layer_norm = nn.LayerNorm(config.hidden_size, eps=1e-12)
self.dropout = nn.Dropout()
def forward(self, input_ids):
# 為輸入序列建立位置ID
seq_length = input_ids.size(1)
position_ids = torch.arange(seq_length, dtype=torch.long).unsqueeze(0)
# 建立token和位置嵌入
token_embeddings = self.token_embeddings(input_ids)
position_embeddings = self.position_embeddings(position_ids)
# 結合token和位置嵌入
embeddings = token_embeddings + position_embeddings
embeddings = self.layer_norm(embeddings)
embeddings = self.dropout(embeddings)
return embeddings
這個嵌入層結合了兩種不同的嵌入:token嵌入和位置嵌入。
對於token嵌入,我們使用輸入的token ID查詢嵌入表,取得每個token的向量表示。對於位置嵌入,我們首先生成一個從0到序列長度-1的位置ID序列,然後同樣透過查表取得每個位置的向量表示。
這兩種嵌入透過簡單相加的方式結合,然後經過層標準化和dropout處理。這種設計允許模型同時學習token的語義表示和它們在序列中的位置訊息。
值得注意的是,位置ID是自動生成的,不需要作為輸入提供。這簡化了模型的使用,同時確保每個位置都有正確的編碼。
完整的Transformer編碼器
現在我們已經有了所有必要的元件,可以構建完整的Transformer編碼器。這個編碼器將嵌入層與多個編碼器層結合:
class TransformerEncoder(nn.Module):
def __init__(self, config):
super().__init__()
self.embeddings = Embeddings(config)
self.layers = nn.ModuleList([TransformerEncoderLayer(config)
for _ in range(config.num_hidden_layers)])
def forward(self, x):
x = self.embeddings(x)
for layer in self.layers:
x = layer(x)
return x
這個Transformer編碼器首先使用嵌入層將輸入token ID轉換為密集向量表示,同時加入位置訊息。然後,這些嵌入依次透過多個編碼器層進行處理。
每個編碼器層都應用自注意力機制和前饋網路,逐步提取和轉換序列中的訊息。層數由設定中的num_hidden_layers
引數決定,這允許根據任務需求和計算資源靈活調整模型深度。
輸出是一個形狀為[batch_size, sequence_length, hidden_size]
的張量,為序列中的每個token提供一個上下文化的表示。這種輸出格式使架構非常靈活,可以輕鬆適應各種應用,如掩碼語言建模或問答任務。
增加分類別頭
Transformer模型通常分為任務無關的主體和任務特定的頭部。到目前為止,我們構建的是模型主體。如果我們想要構建一個文字分類別器,需要在主體上增加一個分類別頭。
我們有每個token的隱藏狀態,但只需要做一個預測。傳統上,這類別模型使用第一個token(通常是特殊的[CLS]標記)進行預測。我們可以增加一個dropout層和一個線性層來進行分類別預測:
class TransformerForSequenceClassification(nn.Module):
def __init__(self, config):
super().__init__()
self.encoder = TransformerEncoder(config)
self.dropout = nn.Dropout(config.hidden_dropout_prob)
self.classifier = nn.Linear(config.hidden_size, config.num_labels)
def forward(self, x):
x = self.encoder(x)[:, 0, :] # 選擇[CLS]標記的隱藏狀態
x = self.dropout(x)
x = self.classifier(x)
return x
這個分類別器擴充套件了現有的編碼器,專門用於序列分類別任務。在前向傳播過程中,它首先透過編碼器處理輸入,然後選擇第一個token(索引0,通常是[CLS]標記)的隱藏狀態。
這個設計根據這樣的假設:[CLS]標記能夠聚合整個序列的訊息,因為自注意力機制允許它關注所有其他token。選擇[CLS]標記的隱藏狀態後,經過dropout處理(防止過擬合),然後透過線性層對映到標籤空間。
輸出是每個類別的未標準化logits,形狀為[batch_size, num_labels]
。這可以進一步透過softmax函式轉換為機率分佈,或直接用於計算損失。
Transformer架構設計的思考
在實作Transformer的過程中,我注意到幾個關鍵的設計決策對模型效能有顯著影響:
Layer Normalization的位置:前置層標準化比後置層標準化提供更穩定的訓練過程,這對於資源有限或需要快速迭代的場景尤為重要。
位置編碼的選擇:雖然可學習的位置編碼最為常見,但在特定場景下,其他方法如正弦-餘弦編碼或相對位置編碼可能更適合。選擇應根據資料集大小和任務特性。
分類別策略:使用[CLS]標記進行分類別是最常見的方法,但其他策略如平均所有token的隱藏狀態或使用特定token也可能適合某些任務。
模型深度與寬度:層數(depth)和隱藏維度(width)的選擇對模型容量和計算需求有直接影響。較深的模型可以學習更複雜的模式,但也需要更多資源和可能面臨更嚴重的最佳化挑戰。
這些設計選擇展示了Transformer架構的靈活性,也解釋了為什麼它能夠適應如此廣泛的自然語言處理任務。透過理解這些元件如何協同工作,我們能夠更有效地調整和最佳化模型以滿足特定需求。
在實際應用中,我發現前置層標準化配合適當的位置編碼往往能提供最佳的效能與穩定性平衡。對於大多數分類別任務,使用[CLS]標記的隱藏狀態進行預測是一個簡單有效的策略,除非任務有特殊需求。
Transformer的優雅之處在於它的模組化設計,允許在保持基本架構不變的同時,針對特定任務進行微調和創新。無論是處理文字、影像還是其他序列資料,這種靈活性使Transformer成為現代深度學習中最重要的架構之一。
Transformer 架構的核心:編碼器與解碼器
在現代自然語言處理領域,Transformer 架構已成為絕對的主流。在前面的討論中,我們已經探索了 Transformer 的整體架構和自注意力機制,現在讓我們更深入地理解編碼器(Encoder)和解碼器(Decoder)的內部運作,以及它們之間的互動方式。
值得注意的是,編碼器-解碼器注意力層(Encoder-Decoder Attention)中的關鍵向量(key)和查詢向量(query)可以有不同的長度。這是因為編碼器和解碼器的輸入通常涉及不同長度的序列。因此,這一層的注意力分數矩陣是矩形的,而非方形。
解碼器的獨特架構
解碼器與編碼器的主要區別在於,解碼器包含兩個注意力子層:
遮罩式多頭自注意力層
遮罩式多頭自注意力(Masked Multi-head Self-attention)確保我們在每個時間步生成的標記僅根據過去的輸出和當前正在預測的標記。若沒有這個機制,解碼器在訓練時可能會透過簡單地複製目標翻譯來"作弊"。遮罩輸入確保任務不會變得過於簡單。
編碼器-解碼器注意力層
編碼器-解碼器注意力層對編碼器堆積積疊的輸出關鍵向量和值向量執行多頭注意力計算,以解碼器的中間表示作為查詢。這使得編碼器-解碼器注意力層學習如何關聯來自兩個不同序列的標記,例如兩種不同語言。解碼器在每個區塊中都能存取編碼器的關鍵向量和值向量。
實作遮罩式自注意力
在自注意力層中引入遮罩的技巧是建立一個下三角矩陣,對角線及以下為1,上方為0:
seq_len = inputs.input_ids.size(-1)
mask = torch.tril(torch.ones(seq_len, seq_len)).unsqueeze(0)
mask[0]
輸出結果為:
tensor([[1., 0., 0., 0., 0.],
[1., 1., 0., 0., 0.],
[1., 1., 1., 0., 0.],
[1., 1., 1., 1., 0.],
[1., 1., 1., 1., 1.]])
這段程式碼建立了一個遮罩矩陣,用於確保解碼器中的自注意力機制不會"看到"未來的標記。torch.tril()
函式建立一個下三角矩陣(lower triangular matrix),其中對角線及以下的元素為1,其餘為0。這個矩陣的大小由輸入序列長度決定,unsqueeze(0)
增加一個批次維度,使矩陣形狀適合批次處理。
一旦有了這個遮罩矩陣,我們可以透過使用 Tensor.masked_fill()
將所有零替換為負無窮,從而防止每個注意力頭窺視未來的標記:
scores.masked_fill(mask == 0, -float("inf"))
輸出結果顯示分數矩陣中上三角部分被設為負無窮:
tensor([[[26.8082, -inf, -inf, -inf, -inf],
[-0.6981, 26.9043, -inf, -inf, -inf],
[-2.3190, 1.2928, 27.8710, -inf, -inf],
[-0.5897, 0.3497, -0.3807, 27.5488, -inf],
[ 0.5275, 2.0493, -0.4869, 1.6100, 29.0893]]],
grad_fn=<MaskedFillBackward0>)
這段程式碼展示瞭如何將遮罩應用於注意力分數。透過將上三角部分(對應未來標記的位置)設為負無窮,當我們對分數應用 softmax 函式時,這些位置的注意力權重將變為零,因為 e^(-∞) = 0。這確保了模型在生成每個標記時只能關注已經生成的標記和當前標記,而不能"偷看"未來的標記。
我們可以輕鬆地透過對之前實作的縮放點積注意力函式做一個小改動來包含這種遮罩行為:
def scaled_dot_product_attention(query, key, value, mask=None):
dim_k = query.size(-1)
scores = torch.bmm(query, key.transpose(1, 2)) / sqrt(dim_k)
if mask is not None:
scores = scores.masked_fill(mask == 0, float("-inf"))
weights = F.softmax(scores, dim=-1)
return weights.bmm(value)
這個改進版的縮放點積注意力函式增加了對遮罩的支援。函式首先計算查詢和關鍵向量之間的點積,然後進行縮放。如果提供了遮罩,就將分數矩陣中遮罩為0的位置填充為負無窮。最後,應用 softmax 函式運算注意力權重,並用這些權重對值向量進行加權求和。這個函式可以同時用於標準自注意力和遮罩式自注意力,取決於是否提供遮罩引數。
從這裡開始,構建解碼器層是一個簡單的過程。實際上,Andrej Karpathy 的 minGPT 實作提供了很好的參考。
解構編碼器-解碼器注意力機制
為了更好地理解編碼器-解碼器注意力機制,我們可以用一個比喻來說明:
想像你(解碼器)正在課堂上參加考試。你的任務是根據前面的單詞預測下一個單詞,這聽起來很簡單,但實際上非常困難。幸運的是,你的鄰居(編碼器)擁有完整的文字。不幸的是,他們是一位外國交換生,文字是用他們的母語寫的。
作為聰明的學生,你們想出了一種作弊方式。你畫了一幅小卡通,描繪了你已經擁有的文字(查詢),並把它交給你的鄰居。他們嘗試找出哪段文字與該描述比對(關鍵),然後畫一幅卡通描述那段文字後面的單詞(值),並將其傳回給你。有了這個系統,你就能順利透過考試。
這個比喻生動地說明瞭編碼器-解碼器注意力的工作原理:解碼器生成查詢,編碼器提供關鍵和值,然後解碼器使用這些訊息來生成下一個標記。