揭秘大模型的魔法:從嵌入向量說起
大家好,我是寫代碼的中年人,上一篇文章我們介紹了詞元的概念及如何訓練自己的詞元,待訓練的數據變成詞元后,我們發現詞元(文本)之間沒有任何聯系,也就是說它們是離散的數據,所以我們沒辦法對詞元進行計算。
將離散的文本轉化為連續的向量表示,即嵌入向量(Embedding Vector)。嵌入向量是大模型處理自然語言的起點,它將人類語言的符號轉化為機器可以理解的數學表示。
本文將以Transformer架構為核心,深入探討嵌入向量的生成過程,剖析其背后的“魔法”,并通過代碼示例展示如何實現這一過程。
嵌入向量的簡介
從上一篇我們已經了解了詞元和詞元ID的概念,最后我們生成了一個詞匯表(Vocabulary),并且知道詞匯表的大小通常在幾萬到幾十萬之間,具體大小取決于模型設計。
詞元ID是離散的整數,無法直接用于神經網絡的數學運算。因此,嵌入層(Embedding Layer)將詞元ID映射為連續的向量表示。嵌入層本質上是一個可學習的查找表,存儲為一個形狀為 [vocab_size, embedding_dim] 的矩陣,其中:
vocab_size:詞匯表的大小。
embedding_dim:每個詞元的向量維度。
詞匯表的概念我們已經了解,嵌入向量的概念可以簡單理解為:你用多少個數字來表示一個詞,維度越高,詞向量表達的語義就越豐富,但也更復雜。
我們要記住的是嵌入向量是模型最早期的“參數矩陣”,通常是隨機初始化的,然后在訓練中慢慢學習。
我們先看一個例子,如下代碼:
import torch
import torch.nn as nn
# 設置打印選項
torch.set_printoptions(threshold=10000, precisinotallow=4, sci_mode=False)
# 參數定義
vocab_size = 10000
embedding_dim = 256
embedding_layer = nn.Embedding(vocab_size, embedding_dim)
# 輸入 token id
token_ids = torch.tensor([101, 102, 103, 104, 105, 106, 107])
# 獲取嵌入向量
embeddings = embedding_layer(token_ids)
# 輸出嵌入矩陣
print("嵌入矩陣:")
print(embeddings)
執行上面代碼后,我們看到程序會輸出如下信息:
嵌入矩陣:
tensor([[ -1.1887, -0.3787, -1.6036, 1.2109, -1.5041,
0.5217, -0.0660, 0.8761, -1.3062, -0.5456,
-2.2370, -0.7596, 0.6463, 1.3679, -0.7995,
-0.8499, -1.1883, -0.4964, -0.9248, 1.3193,
-0.3776, -1.6146, -0.2606, 1.3084, 1.5899,
-0.3184, 0.7106, 0.4439, -1.0974, -0.0911,
0.0765, -1.1273, -2.0399, -0.7867, 0.5819,
....中間信息省略
-0.6946, 0.1002, -0.8110, -1.1093, 0.4499,
-0.5466, 0.8090, 1.3586, -0.4617, 0.0936,
0.4514, -1.0935, 1.1986, 0.5158, 0.7961,
0.1658, 0.9241, -0.2872, -1.5406, 0.6301,
1.3381, -1.6376, 0.5164, -1.1603, -1.0949,
0.7568, -0.8883, -0.0534, -1.1359, -0.1575,
-0.7413]], grad_fn=<EmbeddingBackward0>)
這段代碼到底做了什么事情?我們接下來進行詳解:
定義嵌入矩陣的大小:
vocab_size = 10000
表示你有一個詞匯表(vocabulary),大小是 10,000,意思是你有 10,000 個獨立的詞(或子詞、token),詞匯表的概念可以參照上篇文章的介紹。
embedding_dim = 256
表示每個詞要被映射為一個256維的向量。這就是“嵌入維度”,你可以理解為:
把每個離散的 token 映射到一個連續空間中,變成一個可學習的向量(表示它的“意義”或“語義”)
初始化嵌入層:
embedding_layer = nn.Embedding(vocab_size, embedding_dim)
nn.Embedding(vocab_size, embedding_dim) 是 PyTorch 提供的嵌入層。
它的作用是創建一個大小為 [vocab_size, embedding_dim] 的查找表,每行對應一個 token 的向量。
換句話說,它是一個形狀為 [10000, 256] 的矩陣。每一行是一個詞的向量:
token_id = 0 → [0.1234, -0.5321, ..., 0.0012] # 長度為256
token_id = 1 → [0.3332, -0.8349, ..., -0.2176]
...
token_id = 9999 → [...]
這個矩陣的參數是可訓練的,會隨著模型訓練不斷優化,使得語義相近的 token 向量距離也更近。
定義 token id:
token_ids = torch.tensor([101, 102, 103, 104, 105, 106, 107])
這里創建了一個 tensor,內容是 [101, 102, 103, 104, 105, 106, 107],它代表你輸入的 7 個詞/子詞的索引(ID)。
每個數字表示詞表中的一個詞,例如:
101 → “寫”
102 → “代”
103 → “碼”
104 → “的”
105 → “中”
106 → “年”
107 → “人”
(這里只是舉例,真實情況看 tokenizer)
變為嵌入向量:
embeddings = embedding_layer(token_ids)
把 token_ids [101, 102, 103, 104, 105, 106, 107] 送進嵌入層后,會從嵌入矩陣中取出它們對應的向量,得到:
embeddings.shape == [7, 256]
每個詞變成了一個 256 維的向量,這些向量是浮點數,比如:
embeddings[0] = tensor([ 0.1371, -0.0208, ..., 0.0415]) # token 101 的嵌入
embeddings[1] = tensor([-0.0817, 0.2991, ..., 0.0034]) # token 102 的嵌入
...
輸出的向量是啥?
這些 256 維向量就是詞的語義向量表示(Word Embedding):
它們是模型可訓練參數;
它們的數值是隨機初始化的(除非你加載了預訓練模型);
它們的作用是:把 token 編碼成模型能處理的“連續表示”;
在模型訓練過程中,這些向量會逐步學習到語義,比如 “我” 和 “我們” 的向量距離會比 “我” 和 “電腦” 更近。如何訓練我們后續再講,這里只要明白它們是怎么初始化的和有什么作用就行。
最終經過大量語料訓練之后,每個 token 的 embedding都是模型學習到的語義表示,它不再“隨機”,而是能捕捉詞義的相似性。
大概的流程為:
原始輸入文本 → tokenizer → token_id → embedding向量 → 加入位置編碼 → 輸入Transformer
位置編碼簡介
位置編碼(Positional Encoding)是 Transformer 架構的關鍵組件之一,在Transformer架構中,模型主要依賴自注意力機制來處理輸入序列。然而,自注意力機制本身是無序的,即它不考慮輸入序列中詞或標記(token)的相對位置或絕對位置信息。這會導致模型無法區分序列中不同位置的詞,即使它們的語義完全相同。為了解決這個問題,引入了位置編碼(Positional Encoding),其作用是:
提供位置信息:為序列中的每個位置賦予一個獨特的表示,使模型能夠感知詞的順序和相對位置。
保持序列順序的語義:通過位置編碼,Transformer可以理解序列中詞的排列順序對語義的影響。
支持并行計算:位置編碼是預先計算或固定的(不像RNN那樣依賴序列處理),因此不會影響Transformer的并行化優勢。
常見的位置編碼方法:
位置編碼是在 進入 Transformer 架構的第一層之前添加的,通常在模型的輸入端(即嵌入層之后)。
對于標準 Transformer(如 GPT 或 BERT),位置編碼是直接加到詞嵌入上,作為整個模型的初始輸入。
對于某些變體(如使用 RoPE 的模型),位置信息可能在注意力機制內部通過旋轉矩陣應用,但這仍然發生在 Transformer 層處理之前或作為注意力計算的一部分。
接著上面的嵌入向量代碼,我們先使用正弦/余弦編碼來實現一個位置編碼:
import torch
import torch.nn as nn
import math
# 設置打印選項
torch.set_printoptions(threshold=10000, precisinotallow=4, sci_mode=False)
# 定義位置編碼類
class PositionalEncoding(nn.Module):
def __init__(self, d_model, max_len=5000):
super(PositionalEncoding, self).__init__()
pe = torch.zeros(max_len, d_model)
position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1)
div_term = torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model))
pe[:, 0::2] = torch.sin(position * div_term)
pe[:, 1::2] = torch.cos(position * div_term)
pe = pe.unsqueeze(0) # Shape: (1, max_len, d_model)
self.register_buffer('pe', pe)
def forward(self, x):
# x: (batch_size, seq_len, d_model)
x = x + self.pe[:, :x.size(1), :] # Add positional encoding
return x
# 參數定義
vocab_size = 10000
embedding_dim = 256
embedding_layer = nn.Embedding(vocab_size, embedding_dim)
pos_encoder = PositionalEncoding(d_model=embedding_dim, max_len=5000)
# 輸入 token id
token_ids = torch.tensor([101, 102, 103, 104, 105, 106, 107])
# 獲取嵌入向量
embeddings = embedding_layer(token_ids)
# 輸出嵌入矩陣
print("嵌入矩陣:")
print(embeddings)
# 添加位置編碼
embeddings_with_pe = pos_encoder(embeddings.unsqueeze(0)).squeeze(0) # Add batch dimension and remove it
# 輸出添加位置編碼后的矩陣
print("\n添加位置編碼后的嵌入矩陣:")
print(embeddings_with_pe)
RoPE 旋轉位置編碼:(RoPE 只作用在自注意力中的 Query 和 Key 上,不是 Value,也不是 Embedding 本身,下面代碼只是示例。)
import torch
import torch.nn as nn
import math
# 設置打印選項(便于查看向量)
torch.set_printoptions(threshold=10000, precisinotallow=4, sci_mode=False)
# ========================
# 旋轉位置編碼(RoPE)模塊
# ========================
class RotaryPositionalEncoding(nn.Module):
def __init__(self, dim, max_len=5000, base=10000):
super(RotaryPositionalEncoding, self).__init__()
assert dim % 2 == 0, "RoPE要求維度必須是偶數。"
self.dim = dim
self.max_len = max_len
self.base = base
self._build_cache()
def _build_cache(self):
half_dim = self.dim // 2
inv_freq = 1.0 / (self.base ** (torch.arange(0, half_dim).float() / half_dim)) # [dim/2]
pos = torch.arange(self.max_len).float() # [max_len]
sinusoid = torch.einsum('i,j->ij', pos, inv_freq) # [max_len, dim/2]
self.register_buffer('sin', torch.sin(sinusoid)) # [max_len, dim/2]
self.register_buffer('cos', torch.cos(sinusoid)) # [max_len, dim/2]
def forward(self, x):
"""
輸入:
x: Tensor, shape (batch, seq_len, dim)
輸出:
Tensor, shape (batch, seq_len, dim),應用RoPE后
"""
batch_size, seq_len, dim = x.size()
sin = self.sin[:seq_len].unsqueeze(0).to(x.device) # [1, seq_len, dim/2]
cos = self.cos[:seq_len].unsqueeze(0).to(x.device)
x1 = x[..., 0::2]
x2 = x[..., 1::2]
x_rotated = torch.cat([x1 * cos - x2 * sin, x1 * sin + x2 * cos], dim=-1)
return x_rotated
# ========================
# 主程序:嵌入 + RoPE 演示
# ========================
# 參數定義
vocab_size = 10000
embedding_dim = 256
embedding_layer = nn.Embedding(vocab_size, embedding_dim)
rope_encoder = RotaryPositionalEncoding(dim=embedding_dim, max_len=5000)
# 輸入 token ids(假設是一個樣本)
token_ids = torch.tensor([101, 102, 103, 104, 105, 106, 107]) # [seq_len]
embeddings = embedding_layer(token_ids).unsqueeze(0) # [1, seq_len, dim]
# 應用 RoPE 位置編碼
rope_embeddings = rope_encoder(embeddings).squeeze(0) # [seq_len, dim]
# 打印結果
print("原始嵌入向量:")
print(embeddings.squeeze(0))
print("\n應用 RoPE 后的嵌入向量:")
print(rope_embeddings)
結尾語
在大模型的世界里,嵌入向量和位置編碼就像是兩把開啟理解語言奧秘的鑰匙:前者將離散的語言符號映射到連續的語義空間,后者則幫助模型理解“誰先誰后”、“誰靠誰近”。我們從嵌入矩陣的初始化講起,了解了這些向量是如何從隨機開始,逐步在訓練中學會“懂語言”的;然后走進了位置編碼的演化史,從經典的正弦余弦到如今主流的旋轉位置編碼(RoPE),我們看到了模型如何用巧妙的方式“感知順序”,并最終在注意力機制中扮演關鍵角色。
值得強調的是,RoPE 并不是一種加法編碼,而是一種乘法思維,它精準地嵌入在自注意力中的 Query 和 Key 上,為模型引入位置的相對關系感。這種設計既數學優雅,又計算高效,成為當前大語言模型如 LLaMA、ChatGLM 的標配。
理解這些底層機制,不僅有助于我們更好地使用大模型,更是在 AI 工程實踐中邁出的堅實一步,也是為我們親自訓練一個基礎模型,必須打通的一道關卡。