找回密码
 立即注册
首页 业界区 业界 手撕 Transformer (2):嵌入层和位置编码的实现 ...

手撕 Transformer (2):嵌入层和位置编码的实现

夔新梅 4 小时前
上篇文章讲过,Transformer 可分为四个部分:输入、输出、编码器、解码器。本文主要手撕输入部分。输入部分由嵌入层(Embedding)和位置编码(Positional Encoding)组成。
本文进行嵌入层和位置编码的代码实现和原理讲解。
1 嵌入层

嵌入层的作用:为了将文本中词汇的数字表示转换为向量表示(语义向量),这样后续神经网络就可以对其进行计算了。
1.1 代码实现
  1. import torch
  2. import torch.nn as nn
  3. import math
  4. from torch.autograd import Variable
  5. class Embeddings(nn.Module):
  6.     def __init__(self, d_model, vocab):
  7.         # d_model: 词嵌入的维度
  8.         # vocab: 词表的大小
  9.         super(Embeddings, self).__init__()
  10.         self.lut = nn.Embedding(vocab, d_model)
  11.         self.d_model = d_model
  12.     def forward(self, x):
  13.         # 前向传播
  14.         # x 是输入进模型的文本通过映射后的数字张量
  15.         return self.lut(x) * math.sqrt(self.d_model)
  16. if __name__ == "__main__":
  17.     d_model = 512
  18.     vocab = 1000
  19.     x = Variable(torch.LongTensor([[123, 233, 510, 998], [985, 211, 110, 996]]))
  20.     emb = Embeddings(d_model, vocab)
  21.     emb_result = emb(x)
  22.     print("embedding result: ", emb_result)
  23.     print(emb_result.shape)     # torch.Size([2, 4, 512])
复制代码
在 Embeddings 类中,self.lut = nn.Embedding(vocab, d_model) 会创建一个随机初始化的嵌入矩阵。PyTorch 的 nn.Embedding 模块默认使用均匀分布随机初始化权重。
在模型训练过程中,当计算损失函数并执行反向传播时,嵌入层的权重会接收到梯度,然后通过优化器(如 Adam)进行更新。这样,模型会逐渐学习到更有意义的词向量表示,这些表示会捕捉到词语之间的语义和语法关系。
运行结果:
  1. embedding result:  tensor([[[-49.8672, -21.6785,  18.1069,  ...,   0.2031, -28.3568,   5.5724],
  2.          [-55.8387, -26.6077,  37.4205,  ...,   7.8280,  -5.1322,   8.1475],
  3.          [ 21.9637,   9.6126,  53.4801,  ...,  16.6295,  37.5978,  13.2768],
  4.          [-19.0594, -13.2244,  16.7811,  ...,  16.9383, -46.1544,  -3.1326]],
  5.         [[  9.8451,  22.9543,   3.1216,  ...,  18.1514,  24.2709,  31.3333],
  6.          [ 30.5660,  -9.3572,  -5.8656,  ...,   4.3933,   9.5235,   9.1021],
  7.          [ 14.2475,  28.2354,  49.7318,  ...,   9.2369, -23.4376,  -7.1588],
  8.          [ 15.4746,  40.1049, -19.8356,  ..., -25.1046,  13.6735, -18.5525]]],
  9.        grad_fn=<MulBackward0>)
  10. torch.Size([2, 4, 512])
复制代码
为什么需要学习嵌入向量?
随机初始化的嵌入向量只是初始值,不包含任何语义信息。通过训练,模型会根据具体任务(如机器翻译、文本分类等)的目标,调整嵌入向量,使得相似含义的词在向量空间中距离更近,不同含义的词距离更远。
为什么计算完 Embedding 之后要乘以 \(\sqrt{{d_{model}}}\) ?
  1. self.lut(x) * math.sqrt(self.d_model)
复制代码
放大信号:词嵌入通常是随机初始化的,其方差较小,通过乘以 \(\sqrt{{d_{model}}}\) 来放大嵌入向量的幅度,确保嵌入向量的尺度与位置编码(通常使用正弦/余弦函数生成)相当。
注意此处不是注意力机制中的缩放点积,那个是除以 \(\sqrt{{d_{model}}}\) 。
我们可以单独把 Embedding 的作用拿出来看一下
  1. embedding = nn.Embedding(10, 3)
  2. input1 = torch.LongTensor([[1,2,4,5],[4,3,2,9]])
  3. print("整数张量,表示词ID", input1)
  4. print("input1转成向量表示",embedding(input1))
复制代码
从下面的运行结果可以明显看出 Embedding 的数字表示转向量表示的作用。
  1. 整数张量,表示词ID tensor([[1, 2, 4, 5],
  2.         [4, 3, 2, 9]])
  3. input1转成向量表示 tensor([[[-0.6656,  1.6754, -0.5841],
  4.          [ 1.1583,  0.0122,  0.0297],
  5.          [-1.5521,  1.9699,  0.0168],
  6.          [ 0.9703, -0.0608, -0.6835]],
  7.         [[-1.5521,  1.9699,  0.0168],
  8.          [ 1.1763,  0.1059, -0.6196],
  9.          [ 1.1583,  0.0122,  0.0297],
  10.          [-0.7003,  0.6548,  0.0784]]], grad_fn=<EmbeddingBackward0>)
复制代码
如果词ID中有数字0,计算出的嵌入向量会是 0 吗?从下面的例子中,可以看到,并不是。因为嵌入向量是随机初始化的,并且在训练过程中不断更新。
示例
  1. embedding = nn.Embedding(10, 3)
  2. input2 = torch.LongTensor([[0,2,0,5]])
  3. print("整数张量,表示词ID", input2)
  4. print("input2转成向量表示",embedding(input2))
复制代码
从下面的运行结果可以看出,虽然词ID中有 0,但是嵌入向量中并没有 0 。
  1. 整数张量,表示词ID tensor([[0, 2, 0, 5]])
  2. input2转成向量表示 tensor([[[-2.0432,  0.4369, -0.4257],
  3.          [-0.1574,  0.1013, -0.1821],
  4.          [-2.0432,  0.4369, -0.4257],
  5.          [ 0.0601,  0.9223,  0.3128]]], grad_fn=<EmbeddingBackward0>)
复制代码
在实际应用中,有的时候是需要嵌入向量中有 0 的,让这些参数在训练的过程中不更新。
当数据批量输入进模型时,序列的长度可能不一致,这时候就需要对短序列的特定维度进行补 0 ,使其与最长序列相等。
初始化时,对应的嵌入向量会初始化为 0 。训练时,填充位置的嵌入向量不会被更新(梯度为 0),避免填充位置对模型训练产生干扰。推理时,填充位置的嵌入向量保持为 0,不影响模型对有效序列的处理。
例如,在机器翻译任务中,输入句子:["I love you", "He eats"],假设最长的序列是"I love you",长度为3,短序列为"He eats",长度为2。填充后:[[1, 2, 3], [4, 5, 0]]。
具体代码只需要添加一个参数即可,示例:
  1. embedding = nn.Embedding(10, 3, padding_idx=0)
  2. input3 = torch.LongTensor([[0,2,0,5]])
  3. input4 = torch.LongTensor([[1, 2, 3], [4, 5, 0]])
  4. print("整数张量,表示词ID", input3)
  5. print("input3转成向量表示",embedding(input3))
  6. print("input4转成向量表示",embedding(input4))
复制代码
运行结果
  1. 整数张量,表示词ID tensor([[0, 2, 0, 5]])
  2. input3转成向量表示 tensor([[[ 0.0000,  0.0000,  0.0000],
  3.          [-0.7443,  0.0692,  0.0825],
  4.          [ 0.0000,  0.0000,  0.0000],
  5.          [-0.1140, -0.5122, -0.4336]]], grad_fn=<EmbeddingBackward0>)
  6. input4转成向量表示 tensor([[[-1.0667, -0.9710, -0.4726],
  7.          [-0.7443,  0.0692,  0.0825],
  8.          [-0.8729,  0.7102, -1.5695]],
  9.         [[ 0.7366,  1.0636,  0.5947],
  10.          [-0.1140, -0.5122, -0.4336],
  11.          [ 0.0000,  0.0000,  0.0000]]], grad_fn=<EmbeddingBackward0>)
复制代码
2 位置编码

2.1 为什么需要位置编码?

RNN 和 LSTM 是一个词一个词按顺序进模型,自然知道先后;CNN 有卷积核,能看到局部顺序;Transformer 不含循环结构、也不含卷积操作,的自注意力是并行的,同时看所有词,没有顺序概念。
如果没有位置编码,那么“我爱你”和“你爱我”的词嵌入完全一样,注意力计算结果完全一样。所以需要有位置编码。
位置编码的作用:补上顺序信息
在嵌入向量进入编码器和解码器之前,我们需要把位置信息加入嵌入向量。位置编码与嵌入向量具有相同的维度 \(d_{\text{model}}\),二者可以直接相加。
我们在本文只讨论 Transformer 原论文中使用的位置编码——正余弦位置编码,这是一种相对位置编码。这种位置编码是固定、不可训练的。但不是所有的位置编码都是不可训练的,如 BERT 和 GPT-1/2 用的位置编码是可学习的位置嵌入,我们在此处不展开。
Transformer 使用不同频率的正余弦函数构造位置编码,其公式如下:

\[PE_{(pos,2i)} = \sin(\frac{pos}{10000^{2i/d_{\text{model}}}})\]

\[PE_{(pos,2i+1)} = \cos(\frac{pos}{10000^{2i/d_{\text{model}}}})\]
其中,\(pos\)是词在句子中的位置(比如第1个词,第2个词);\(d_{\text{model}}\)是词向量的维度(在原论文中是 512);\(i\)是维度的索引。因为公式是把偶数维度 (\(2i\)) 给 \(\sin\),奇数维度 (\(2i+1\)) 给 \(\cos\),所以 \(i\) 的取值范围是 \(0, 1, 2, ..., \frac{d_{\text{model}}}{2}-1\)。
第一次看到这两个公式的时候,很多人都是一头雾水。比如,为什么是 10000 ?为什么要计算 \(\frac{d_{\text{model}}}{2}\)?其实这不是严格“数学推导出来”的,而是根据设计目标推导出的一个合理形式。这里把公式记住就行,本文不做展开,详情见这篇文章:浅谈正余弦位置编码的数学原理
2.2 代码实现:
  1. class PositionalEncoding(nn.Module):
  2.     "Implement the PE function."
  3.     def __init__(self, d_model, dropout, max_len=5000):
  4.         """
  5.         d_model: 词嵌入的维度
  6.         dropout: 丢弃神经元的概率
  7.         max_len: 每个句子的最大长度
  8.         """
  9.         super(PositionalEncoding, self).__init__()
  10.         self.dropout = nn.Dropout(p=dropout)
  11.         # 初始化位置编码矩阵
  12.         pe = torch.zeros(max_len, d_model)
  13.         # torch.arange(0, max_len)创建一维张量,此时张量的形状为 torch.Size([max_len])
  14.         # unsqueeze(dim) 表示在指定维度位置插入一个新维度
  15.         # unsqueeze(0):在第 0 维插入,形状变化 torch.Size([max_len]) → torch.Size([1, max_len])
  16.         # unsqueeze(1):在第 1 维插入,形状变化 torch.Size([max_len]) → torch.Size([max_len, 1])
  17.         # 这样设计是为了后续与 div_term 进行广播运算时,能正确计算出位置编码矩阵
  18.         position = torch.arange(0, max_len).unsqueeze(1)
  19.         # 生成不同频率的缩放因子,用于后续的正弦和余弦计算
  20.         div_term = torch.exp(
  21.             # torch.arange(0, d_model, 2):生成从0到d_model-1,步长为2的序列(如[0, 2, 4, ..., d_model-2])
  22.             # math.log(10000.0):自然对数,作为频率的基数
  23.             torch.arange(0, d_model, 2) * -(math.log(10000.0) / d_model)
  24.         )
  25.         # 对位置编码张量的偶数维度(从0开始,步长为2)应用正弦函数
  26.         pe[:, 0::2] = torch.sin(position * div_term)
  27.         # 对位置编码张量的奇数维度(从1开始,步长为2)应用余弦函数
  28.         pe[:, 1::2] = torch.cos(position * div_term)
  29.         # 在第 0 维插入一个新维度,作为后续的 batch_size 维度
  30.         # [max_len, d_model] -> [1, max_len, d_model]
  31.         pe = pe.unsqueeze(0)
  32.         # 将 pe 注册为模型的缓冲区,使其成为模型的一部分,自动保存和加载,不参与梯度计算
  33.         self.register_buffer("pe", pe)
  34.     def forward(self, x):   # x 的形状 [batch_size, seq_len, d_model]
  35.         # self.pe[:, : x.size(1)],预计算位置编码张量 [1, max_len, d_model] 变为 [1, seq_len, d_model]
  36.         # .requires_grad_(False),明确指定位置编码不参与梯度计算
  37.         # x + ...,通过 pytorch 的广播机制,位置编码会自动扩展为 [batch_size, seq_len, d_model]
  38.         x = x + self.pe[:, : x.size(1)].requires_grad_(False)
  39.         # dropout 随机将部分神经元的输出置为0,防止过拟合
  40.         return self.dropout(x)
复制代码
2.3 代码和公式之间的关联

步骤 1:构造位置索引矩阵 position
  1. position = torch.arange(0, max_len).unsqueeze(1)
复制代码

  • torch.arange(0, max_len) 生成一个一维张量 [0, 1, 2, ..., max_len-1],形状为 [max_len]。
  • .unsqueeze(1) 在第 1 维(列方向)插入一个维度,得到形状 [max_len, 1] 的列向量。
为什么需要列向量? 因为后面要与频率缩放因子 div_term(行向量)进行广播乘法,生成一个 [max_len, d_model/2] 的矩阵,其中每个元素是 pos * factor_i。
步骤 2:计算频率缩放因子 div_term
  1. div_term = torch.exp(
  2.     torch.arange(0, d_model, 2) * -(math.log(10000.0) / d_model)
  3. )
复制代码

  • torch.arange(0, d_model, 2) 生成 [0, 2, 4, ..., d_model-2],这些值正是公式中的 \(2i\)。
  • math.log(10000.0) 是自然对数 \(\ln(10000)\)。
  • 将 [2i] 乘以 -(ln(10000)/d_model),得到 -(2i * ln(10000))/d_model。
  • 再取指数 exp,得到 exp(-(2i * ln(10000))/d_model)。
根据指数和对数性质:

\[\exp\left(-\frac{2i \ln(10000)}{d_{\text{model}}}\right) = 10000^{-2i/d_{\text{model}}}\]
这正是公式中分母部分的倒数。也就是说,div_term 实际上是一个向量,其第 \(i\) 个元素为:

\[\text{div_term} = 10000^{-2i/d_{\text{model}}}\]
步骤 3:计算 position * div_term
position 形状为 torch.size([max_len, 1]),div_term 形状为 torch.size([d_model/2])。通过广播机制,两者相乘得到一个形状为 [max_len, d_model/2] 的矩阵,矩阵的每个元素为:

\[\text{position}[pos] \times \text{div_term} = pos \cdot 10000^{-2i/d_{\text{model}}}\]
这正是正弦/余弦函数的自变量
步骤 4:填充偶数和奇数维度

  • pe[:, 0::2] 选取所有行、从第 0 列开始每隔一列(即偶数索引列),赋值为 sin(position * div_term)。这样就实现了:

\[PE_{(pos, 2i)} = \sin\left(pos \cdot 10000^{-2i/d_{\text{model}}}\right)\]

  • pe[:, 1::2] 选取所有行、从第 1 列开始每隔一列(即奇数索引列),赋值为 cos(position * div_term)。这样就实现了:

\[PE_{(pos, 2i+1)} = \cos\left(pos \cdot 10000^{-2i/d_{\text{model}}}\right)\]
为什么代码中使用指数和对数变换?
直接计算 10000 ** (-2i/d_model) 也可以,但存在两个问题:

  • 幂运算在深度学习中可能不如指数对数稳定且高效。
  • 使用 exp 和 log 可以避免显式的除法,更适合在 GPU 上并行计算。
通过恒等变换:

\[10000^{-2i/d_{\text{model}}} = \exp\left(-\frac{2i}{d_{\text{model}}} \ln(10000)\right)\]
我们可以用一次 exp 和一次乘法完成所有频率的计算,简洁高效。
总结

词嵌入(Token Embedding):负责语义(这个词是什么意思)
位置编码(Positional Encoding):负责位置(这个词排在第几位)

来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!

相关推荐

您需要登录后才可以回帖 登录 | 立即注册