import torch
import torch.nn as nn
from torch import Tensor
print('PyTorch version:', torch.__version__)PyTorch version: 2.12.0+xpu
jshn9515
2026-04-09
2026-05-04
在前面的 self-attention 里,我们已经看到,每个 token 都可以直接和序列中的所有 token 建立联系。相比 RNN 只能一步一步传递信息,self-attention 的好处就是,它可以并行计算,也更容易捕捉长距离依赖。但 self-attention 也有一个很重要的问题:它本身并不知道 token 的顺序。
也就是说,如果我们只把一组 token embedding 输入 self-attention,那么 attention 看到的只是一堆向量,而不是一个有顺序的句子。它可以计算 token 之间的相似度,可以决定该关注谁,但它并不能天然地区分某个 token 是出现在第一个位置,还是第五个位置。
比如下面两个句子:
1. dog bites man
2. man bites dog
这两个句子包含的词完全一样,但顺序不同,意思也完全不同。如果模型只知道有哪些词,而不知道它们出现的顺序,就很难判断这两句话的真正含义。
这就是 positional encoding 要解决的问题。
PyTorch version: 2.12.0+xpu
假设输入序列是:
\[ x_1, x_2, x_3, \dots, x_n \]
在 self-attention 中,每个位置都会生成自己的 query、key 和 value:
\[ Q = XW_Q,\quad K = XW_K,\quad V = XW_V \]
然后通过 scaled dot-product attention 计算:
\[ \operatorname{Attention}(Q, K, V) = \operatorname{softmax} \left(\frac{QK^\top}{\sqrt{d_k}} \right)V \]
这个计算过程本身并没有显式使用位置编号。它只关心 token 向量之间的匹配程度。
如果没有加入位置编码,我们把输入 token 的顺序打乱,只要对应的 token embedding 也一起被打乱,self-attention 仍然可以照常计算。更准确地说,没有位置编码的 self-attention 对输入排列是排列等变的。输入顺序怎么变,输出顺序也会怎么变。当 dog 出现在第 1 位和第 3 位时,它的 token embedding 本身是一样的。如果不额外加入 position embedding,self-attention 看到的仍然只是同一个 dog,而不是第 1 位的 dog 或者第 3 位的 dog。
这和 RNN 很不一样。RNN 是按照时间步一个一个读入 token 的。第一个 token 先参与计算,第二个 token 后参与计算,隐藏状态沿着序列方向不断传递。因此,顺序信息自然体现在计算路径中。即使不额外加入位置编码,RNN 也能区分“先看到 dog 再看到 man”和“先看到 man 再看到 dog”。
但是,也正因为这种顺序递归计算的属性,导致 RNN 的训练速度极慢。Transformer 之所以能高效并行,正是因为它不再按顺序递归计算。这种做法带来了并行性,但也让模型失去了顺序信息。所以,如果我们想让 Transformer 理解序列,就必须额外告诉它,这个 token 是第几个。
这就是 positional encoding 的核心作用。
既然 token embedding 表示这个 token 是什么,那么我们也可以给每个位置一个 embedding,表示这个 token 在哪里。
假设第 \(i\) 个 token 的词向量是 \(x_i\),第 \(i\) 个位置的位置向量是 \(p_i\),那么输入 Transformer 之前,我们可以把它们相加:
\[ z_i = x_i + p_i \]
也可以写成矩阵形式:
\[ Z = X + P \]
其中 \(X\) 是 token embedding,\(P\) 是 positional embedding。
这样一来,模型拿到的就不再只是 token 本身的信息,而是 token 信息和位置信息的组合。比如:
\[ \mathrm{embedding}(\mathrm{dog}) + \mathrm{position}(1) \]
和
\[ \mathrm{embedding}(\mathrm{dog}) + \mathrm{position}(3) \]
就会变成两个不同的表示。即使它们对应的是同一个词,只要出现在不同位置,输入到 Transformer 里的向量也不同。
这一步非常关键。因为后面的 \(Q\)、\(K\)、\(V\) 都是由输入向量线性变换得到的:
\[ q_i = z_i W_Q,\quad k_i = z_i W_K,\quad v_i = z_i W_V \]
所以,一旦 \(z_i\) 中包含了位置信息,那么 attention 在计算匹配关系时,也就有机会利用位置信息。
到这里,大家可能会有一个问题:既然 token embedding 和 positional encoding 是两种不同信息,为什么通常是把它们相加,而不是拼接起来?
比如我们为什么不用:
\[ z_i = [x_i; p_i] \]
而是用:
\[ z_i = x_i + p_i \]
一个直观的解释是:
相加可以让 token 信息和位置信息处在同一个表示空间里。
Transformer 后面的层并不会把词信息和位置信息分开处理。它看到的只是一个向量。因此,把两者相加,相当于在原来的 token 表示上加入一个和位置有关的偏移量,让同一个 token 在不同位置拥有略微不同的表示。根据乘法的分配律,线性变换会同时作用在 token 信息和位置信息上:
\[ q_i = (x_i + p_i) W_Q = x_i W_Q + p_i W_Q \]
这样模型就可以在 attention 计算中自然地利用位置信息。
这样做还有一个工程上的好处:相加不会改变向量维度。如果 token embedding 的维度是 \(d_\mathrm{model}\),positional encoding 的维度也设成 \(d_\mathrm{model}\),那么相加之后的结果仍然是 \(d_\mathrm{model}\)。如果使用拼接,维度会变成 \(2d_\mathrm{model}\),后面的所有线性层都要跟着改变。这当然也不是不可以,但会让模型结构更复杂。
所以,相加并不是唯一选择,而是一种简单、有效、实现方便的设计。
在原始 Transformer 论文中,作者使用的是一种固定的正弦位置编码。它不是通过训练学出来的,而是直接由公式生成。
对于位置 \(pos\) 和维度 \(i\),位置编码定义为:
\[ \begin{align*} PE_{(pos, 2i)} &= \sin \left(\frac{pos}{10000^{2i / d_\mathrm{model}}} \right) \\ PE_{(pos, 2i+1)} &= \cos \left(\frac{pos}{10000^{2i / d_\mathrm{model}}} \right) \end{align*} \]
这个公式看起来有点复杂,但核心思想其实很简单:
用不同频率的正弦和余弦函数来表示不同位置。
偶数维使用 sine,奇数维使用 cosine。不同维度对应不同的波长,有些维度变化得很快,有些维度变化得很慢。这样一来,每个位置都会得到一个独特的向量表示。
比如,靠前的位置和靠后的位置会对应不同的正弦、余弦值,相邻位置之间的编码也会有连续变化。模型可以利用这些变化感知 token 的绝对位置,也可以通过位置编码之间的关系学习相对位置模式。
更直观地说,正弦位置编码给序列中的每个位置分配了一个坐标。这个坐标不是一个简单的整数,而是一个高维向量。Transformer 不直接知道这是第 5 个 token,但它可以通过这个位置向量知道这个 token 和其他 token 在位置上有什么关系。
下面我们用 PyTorch 实现一下正弦位置编码:
class SinusoidalPositionalEncoding(nn.Module):
def __init__(self, embed_dim: int, max_len: int = 5000):
super().__init__()
self.embed_dim = embed_dim
self.max_len = max_len
position = torch.arange(max_len).unsqueeze(1)
exp_term = torch.arange(0, embed_dim, 2) / embed_dim
div_term = torch.pow(10000.0, exp_term)
pe = torch.zeros(max_len, embed_dim)
pe[:, 0::2] = torch.sin(position / div_term)
pe[:, 1::2] = torch.cos(position / div_term[: pe[:, 1::2].size(1)])
# Add a batch dimension for broadcasting
self.register_buffer('pe', pe.unsqueeze(0))
def forward(self, x: Tensor) -> Tensor:
if x.size(1) > self.max_len:
raise AssertionError(f'Sequence length {x.size(1)} exceeds {self.max_len}.')
seq_len = x.size(1)
x = x + self.pe[:, :seq_len] # type: ignore
return x
x = torch.zeros(2, 32, 512) # (batch_size, seq_len, d_model)
pos_encoder = SinusoidalPositionalEncoding(embed_dim=512)
output = pos_encoder(x)
print('Output shape:', output.shape)Output shape: torch.Size([2, 32, 512])
这里有一个细节是:
我们没有把 pe 定义成 nn.Parameter,因为正弦位置编码不是训练参数。它不需要被优化器更新,但它仍然应该跟着模型一起保存、加载,并且在调用 .to(device) 时自动移动到对应设备上。所以这里用的是 register_buffer。
除了固定的正弦位置编码,还有一种更直接的做法:把位置也当成一种 embedding,让模型自己学习。
这时,我们会定义一个位置 embedding 矩阵:
\[ P \in \mathbb{R}^{L \times d_\mathrm{model}} \]
其中 \(L\) 是最大序列长度,\(d_\mathrm{model}\) 是 embedding 维度。第 \(pos\) 行就是位置 \(pos\) 对应的位置向量。
然后和前面一样,把 token embedding 和 position embedding 相加:
\[ z_i = x_i + p_i \]
这种方法非常直观:既然词向量可以通过训练学出来,那么位置向量也可以通过训练学出来。
很多现代模型都会使用可学习的位置嵌入。它的优点是灵活,模型可以根据任务自己学习什么样的位置信息最有用。缺点是它通常依赖预设的最大长度。如果模型训练时只学到了最多 \(L\) 个位置,那么在推理时遇到更长的序列,就不一定能自然泛化。
相比之下,正弦位置编码是由公式生成的,所以理论上可以扩展到更长的位置。不过在实际模型里,长上下文泛化还会受到很多其他因素影响,不能只看位置编码这一点。
同样,可学习的位置嵌入也很容易实现:
class LearnablePositionalEmbedding(nn.Module):
def __init__(self, embed_dim: int, max_len: int = 5000):
super().__init__()
self.embed_dim = embed_dim
self.max_len = max_len
self.pe = nn.Embedding(max_len, embed_dim)
def forward(self, x: Tensor) -> Tensor:
if x.size(1) > self.max_len:
raise AssertionError(f'Sequence length {x.size(1)} exceeds {self.max_len}.')
seq_len = x.size(1)
positions = torch.arange(seq_len, device=x.device)
pos_emb = self.pe(positions)
x = x + pos_emb.unsqueeze(0)
return x
x = torch.zeros(2, 32, 512) # (batch_size, seq_len, d_model)
pos_encoder = LearnablePositionalEmbedding(embed_dim=512)
output = pos_encoder(x)
print('Output shape:', output.shape)Output shape: torch.Size([2, 32, 512])
与正弦位置编码不同的是,这里的位置向量是模型参数,会随着训练一起更新。
在标准 Transformer 结构中,positional encoding 通常加在最开始的 token embedding 上:
\[ Z = \mathrm{TokenEmbedding}(X) + \mathrm{PositionalEncoding} \]
然后这个 \(Z\) 再输入到 Transformer blocks 里。
也就是说,positional encoding 不是在每一层 attention 里单独加一次,而是在输入阶段先把位置信息注入进去。后面的每一层 self-attention 和 feed-forward network 都会基于这个包含位置信息的表示继续计算。
完整过程可以简单写成:
\[ X \rightarrow \text{Token Embedding} \rightarrow +\ \text{Positional Encoding} \rightarrow \text{Transformer Blocks} \]
这样做的效果就是,从第一层开始,模型就能同时看到 token 内容和 token 位置。
当然,后来的 Transformer 变体里也出现了很多不同的位置建模方式,比如相对位置编码、RoPE、ALiBi 等。它们不一定都是简单地把位置向量加到输入 embedding 上,也有的是直接修改 attention score 的计算方式。不过在这一节,我们先理解最基础的做法:给输入补上位置信息。
这一节里,我们解决了 self-attention 的一个核心问题:它本身没有天然的顺序感。
RNN 通过按时间步递归计算,自然带有顺序信息;而 Transformer 为了并行计算,放弃了这种递归结构。因此,Transformer 需要额外加入 positional encoding,让模型知道每个 token 在序列中的位置。
最常见的做法是把 token embedding 和 positional encoding 相加:
\[ z_i = x_i + p_i \]
这样,同一个 token 出现在不同位置时,会得到不同的输入表示。后续生成 \(Q\)、\(K\)、\(V\) 时,位置信息也会被带入 attention 的计算中。
到这里,Transformer 的几个关键组件已经逐渐拼起来了:self-attention 负责让 token 之间交换信息,multi-head attention 让模型从多个表示空间并行建模关系,而 positional encoding 则补上了序列顺序。下一节,我们就把这些组件组合起来,看看完整的 Transformer Encoder 是什么样子的。