大家好,我是写代码的中年人,上一篇文章我们介绍了词元的概念及如何训练自己的词元,待训练的数据变成词元后,我们发现词元(文本)之间没有任何联系,也就是说它们是离散的数据,所以我们没办法对词元进行计算。
将离散的文本转化为连续的向量表示,即嵌入向量(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 工程实践中迈出的坚实一步,也是为我们亲自训练一个基础模型,必须打通的一道关卡。