- 人工智能
- 深度学习
- 机器学习
- 教程
【免费下载链接】d2l-zh
《动手学深度学习》:面向中文读者、能运行、可讨论。中英文版被70多个国家的500多所大学用于教学。
导读
自注意力(Self-Attention)与位置编码(Positional Encoding)是现代 Transformer 架构的两块基石:前者让序列中任意两个词元可以直接建立依赖关系,后者则在并行计算放弃顺序操作后,把词元的位置信息重新注入输入表示。本文以《动手学深度学习》(d2l-zh)仓库中 chapter_attention-mechanisms/self-attention-and-positional-encoding.md 为主线,从自注意力的定义出发,对比 CNN、RNN 与自注意力三种序列编码架构的复杂度差异,并完整讲解正弦/余弦固定位置编码的公式、四框架(MXNet、PyTorch、TensorFlow、Paddle)实现以及绝对/相对位置信息的数学性质。读完本文,你将能够独立实现并调参使用自注意力层与位置编码模块,为后续理解 Transformer 打下基础。
一、自注意力:查询、键、值来自同一组输入
在深度学习中,序列编码的传统方案是卷积神经网络(CNN)或循环神经网络(RNN)。引入注意力机制后,一个自然的想法是:把词元序列直接送入注意力池化(Attention Pooling),让同一组词元同时充当查询(Query)、键(Key)和值(Value)。此时每个查询都会关注所有的键-值对并生成一个注意力输出——由于查询、键、值来自同一组输入,这种机制被称为自注意力(Self-Attention),也叫内部注意力(Intra-Attention)。
1.1 数学定义
给定一个由 $n$ 个词元组成的输入序列 $\mathbf{x}_1, \ldots, \mathbf{x}_n$,其中任意 $\mathbf{x}_i \in \mathbb{R}^d$($1 \leq i \leq n$),该序列的自注意力输出是一个长度相同的序列 $\mathbf{y}_1, \ldots, \mathbf{y}_n$:
$$\mathbf{y}_i = f(\mathbf{x}_i, (\mathbf{x}_1, \mathbf{x}_1), \ldots, (\mathbf{x}_n, \mathbf{x}_n)) \in \mathbb{R}^d$$
其中 $f$ 是注意力汇聚函数(其定义详见仓库中 attention-scoring-functions.md 一节的注意力汇聚公式)。可以看到,第 $i$ 个输出词元不仅依赖自身,还通过注意力汇聚整合了全部 $n$ 个输入词元的信息——这正是"每个词元直接连接到任何其他词元"的含义。
1.2 用多头注意力实现自注意力
在《动手学深度学习》中,自注意力并不是单独实现一个模块,而是复用此前章节实现的d2l.MultiHeadAttention类:把同一个张量 $X$ 同时作为查询、键、值传入即可。下面的代码基于多头注意力对一个形状为(批量大小,时间步数目/词元序列长度,$d$)的张量完成自注意力计算,输出与输入形状相同:
# MXNet 版 num_hiddens, num_heads = 100, 5 attention = d2l.MultiHeadAttention(num_hiddens, num_heads, 0.5) attention.initialize()# PyTorch 版 num_hiddens, num_heads = 100, 5 attention = d2l.MultiHeadAttention(num_hiddens, num_hiddens, num_hiddens, num_hiddens, num_heads, 0.5) attention.eval()# TensorFlow 版 num_hiddens, num_heads = 100, 5 attention = d2l.MultiHeadAttention(num_hiddens, num_hiddens, num_hiddens, num_hiddens, num_heads, 0.5)# Paddle 版 num_hiddens, num_heads = 100, 5 attention = d2l.MultiHeadAttention(num_hiddens, num_hiddens, num_hiddens, num_hiddens, num_heads, 0.5) attention.eval()传入全 1 张量并验证输出形状(MXNet/PyTorch/Paddle 版):
batch_size, num_queries, valid_lens = 2, 4, d2l.tensor([3, 2]) X = d2l.ones((batch_size, num_queries, num_hiddens)) attention(X, X, X, valid_lens).shapeTensorFlow 版需要显式传training=False:
batch_size, num_queries, valid_lens = 2, 4, tf.constant([3, 2]) X = tf.ones((batch_size, num_queries, num_hiddens)) attention(X, X, X, valid_lens, training=False).shape这里传入的三个X分别是查询、键和值,valid_lens指定每个序列实际有效的词元个数(掩蔽掉填充位置)。执行后输出形状仍为(2, 4, 100)——自注意力保持了序列长度与隐藏维度不变。
1.3 源码视角:多头注意力如何计算自注意力
从仓库源码可以印证上述调用关系。以 PyTorch 版为例,MultiHeadAttention定义在 d2l/torch.py:其构造函数接收key_size, query_size, value_size, num_hiddens, num_heads, dropout等参数,内部依次是缩放点积注意力d2l.DotProductAttention(源码见 d2l/torch.py,其核心是scores = torch.bmm(queries, keys.transpose(1,2)) / math.sqrt(d))以及四组线性变换W_q、W_k、W_v、W_o。前向过程大致为:
- 用
W_q、W_k、W_v将查询、键、值投影到隐藏维度; - 通过
transpose_qkv(d2l/torch.py)把张量重排为(batch_size * num_heads, 序列长度, num_hiddens / num_heads),从而把注意力计算拆到多个头上并行执行; - 对
valid_lens用repeat_interleave按头数复制,保证每个头使用相同的有效长度掩蔽; - 调用缩放点积注意力得到每个头的输出,再用
transpose_output(d2l/torch.py)逆转重排、拼接各头结果,最后经W_o输出。
MXNet 版(d2l/mxnet.py)、TensorFlow 版(d2l/tensorflow.py)与 Paddle 版(d2l/paddle.py)逻辑完全一致,只是框架 API 不同(如 MXNet 用nn.Dense、TensorFlow 用tf.keras.layers.Dense)。当查询、键、值是同一个张量时,这个模块就是纯粹的自注意力。
二、三种序列编码架构对比:CNN、RNN 与自注意力
自注意力并非在所有维度上都"完胜":理解它的优势与代价,最好的方式是与 CNN、RNN 放在同一坐标系下比较。下面统一考虑"将由 $n$ 个词元组成的序列映射到另一个等长序列,每个词元由 $d$ 维向量表示"这一任务,从计算复杂性、顺序操作数量、最大路径长度三个指标展开(顺序操作会妨碍并行计算;任意两个序列位置组合之间的路径越短,越容易学习远距离依赖关系)。
2.1 卷积神经网络:$\mathcal{O}(knd^2)$ 计算量与 $\mathcal{O}(n/k)$ 路径
考虑一个卷积核大小为 $k$ 的卷积层。由于序列长度是 $n$,输入与输出通道数都是 $d$,卷积层的计算复杂度为 $\mathcal{O}(knd^2)$。从架构图可以看到,CNN 是分层的:顺序操作数为 $\mathcal{O}(1)$,最大路径长度为 $\mathcal{O}(n/k)$。例如图中卷积核大小为 3 的双层卷积网络中,$\mathbf{x}_1$ 与 $\mathbf{x}_5$ 处于同一感受野内——信息要跨越 $\mathcal{O}(n/k)$ 层才能打通长距离依赖。
2.2 循环神经网络:$\mathcal{O}(nd^2)$ 计算量与 $\mathcal{O}(n)$ 路径
RNN 按时间步逐个更新隐状态。每一步中,$d \times d$ 权重矩阵与 $d$ 维隐状态的乘法计算复杂度为 $\mathcal{O}(d^2)$;由于序列长度为 $n$,整个 RNN 层的计算复杂度为 $\mathcal{O}(nd^2)$。更关键的是,它有 $\mathcal{O}(n)$ 个顺序操作无法并行化,最大路径长度同样是 $\mathcal{O}(n)$——这是 RNN 难以捕捉远距离依赖、也难以利用现代并行硬件的根本原因。
2.3 自注意力:$\mathcal{O}(n^2d)$ 计算量,但路径最短
在自注意力中,查询、键、值都是 $n \times d$ 矩阵。缩放点积注意力(对应attention-scoring-functions.md中定义的公式eq_softmax_QK_V)的计算过程为:$n \times d$ 矩阵乘以 $d \times n$ 矩阵,得到的 $n \times n$ 注意力分数矩阵再乘以 $n \times d$ 矩阵,因此自注意力具有 $\mathcal{O}(n^2d)$ 的计算复杂度。但正如架构图所示,每个词元都通过自注意力直接连接到任何其他词元,因此只有 $\mathcal{O}(1)$ 个顺序操作,可以高度并行;最大路径长度也是 $\mathcal{O}(1)$,远距离依赖只需一步即可建立。
2.4 结论:并行优势与平方级代价
| 架构 | 计算复杂性 | 顺序操作数 | 最大路径长度 |
|---|---|---|---|
| 卷积神经网络 | $\mathcal{O}(knd^2)$ | $\mathcal{O}(1)$ | $\mathcal{O}(n/k)$ |
| 循环神经网络 | $\mathcal{O}(nd^2)$ | $\mathcal{O}(n)$ | $\mathcal{O}(n)$ |
| 自注意力 | $\mathcal{O}(n^2d)$ | $\mathcal{O}(1)$ | $\mathcal{O}(1)$ |
总而言之,CNN 与自注意力都拥有并行计算优势,且自注意力的最大路径长度最短。但其计算复杂度是序列长度的二次方,因此在很长的序列上计算会非常慢——这也是后续稀疏注意力、线性注意力等改进方向的出发点。
三、位置编码:为并行化后的序列重新注入顺序信息
3.1 为什么要位置编码
RNN 是逐个、重复地处理词元的,顺序信息天然隐含在迭代过程中;而自注意力因并行计算彻底放弃了顺序操作——如果把词元嵌入直接送入自注意力层,模型将完全无法区分"我爱你"与"你爱我"。为了解决这个问题,可以在输入表示中添加位置编码(Positional Encoding),注入绝对的或相对的位置信息。位置编码可以通过学习得到,也可以直接固定;本文详细讲解的是基于正弦函数和余弦函数的固定位置编码(源自 Transformer 原论文)。
3.2 正弦/余弦位置编码的数学形式
假设输入表示 $\mathbf{X} \in \mathbb{R}^{n \times d}$ 包含一个序列中 $n$ 个词元的 $d$ 维嵌入。位置编码使用一个相同形状的位置嵌入矩阵 $\mathbf{P} \in \mathbb{R}^{n \times d}$,最终输出为 $\mathbf{X} + \mathbf{P}$(逐元素相加)。矩阵第 $i$ 行、第 $2j$ 列与第 $2j+1$ 列上的元素定义为:
$$p_{i, 2j} = \sin\left(\frac{i}{10000^{2j/d}}\right), \quad p_{i, 2j+1} = \cos\left(\frac{i}{10000^{2j/d}}\right)$$
其中 $i$ 是词元在序列中的位置,$j$ 是编码维度下标。记 $\omega_j = 1/10000^{2j/d}$,则每个位置 $i$ 在维度对 $(2j, 2j+1)$ 上对应一对 $(\sin(i\omega_j), \cos(i\omega_j))$。分母中的 $10000$ 控制频率衰减的速率:维度越靠后($j$ 越大),$2j/d$ 越大,$\omega_j$ 越小,对应正弦/余弦函数的频率越低。
3.3 四种框架的完整实现
下面给出PositionalEncoding类在四个框架下的完整实现。该类在仓库中同时保存在章节文档与 d2l/ 工具包中(PyTorch 版见 d2l/torch.py,MXNet 版见 d2l/mxnet.py,TensorFlow 版见 d2l/tensorflow.py,Paddle 版见 d2l/paddle.py),可直接通过d2l.PositionalEncoding使用。
MXNet 版(nn.Block):
#@save class PositionalEncoding(nn.Block): """位置编码""" def __init__(self, num_hiddens, dropout, max_len=1000): super(PositionalEncoding, self).__init__() self.dropout = nn.Dropout(dropout) # 创建一个足够长的P self.P = d2l.zeros((1, max_len, num_hiddens)) X = d2l.arange(max_len).reshape(-1, 1) / np.power( 10000, np.arange(0, num_hiddens, 2) / num_hiddens) self.P[:, :, 0::2] = np.sin(X) self.P[:, :, 1::2] = np.cos(X) def forward(self, X): X = X + self.P[:, :X.shape[1], :].as_in_ctx(X.ctx) return self.dropout(X)PyTorch 版(nn.Module):
#@save class PositionalEncoding(nn.Module): """位置编码""" def __init__(self, num_hiddens, dropout, max_len=1000): super(PositionalEncoding, self).__init__() self.dropout = nn.Dropout(dropout) # 创建一个足够长的P self.P = d2l.zeros((1, max_len, num_hiddens)) X = d2l.arange(max_len, dtype=torch.float32).reshape( -1, 1) / torch.pow(10000, torch.arange( 0, num_hiddens, 2, dtype=torch.float32) / num_hiddens) self.P[:, :, 0::2] = torch.sin(X) self.P[:, :, 1::2] = torch.cos(X) def forward(self, X): X = X + self.P[:, :X.shape[1], :].to(X.device) return self.dropout(X)TensorFlow 版(tf.keras.layers.Layer):
#@save class PositionalEncoding(tf.keras.layers.Layer): """位置编码""" def __init__(self, num_hiddens, dropout, max_len=1000): super().__init__() self.dropout = tf.keras.layers.Dropout(dropout) # 创建一个足够长的P self.P = np.zeros((1, max_len, num_hiddens)) X = np.arange(max_len, dtype=np.float32).reshape( -1,1)/np.power(10000, np.arange( 0, num_hiddens, 2, dtype=np.float32) / num_hiddens) self.P[:, :, 0::2] = np.sin(X) self.P[:, :, 1::2] = np.cos(X) def call(self, X, **kwargs): X = X + self.P[:, :X.shape[1], :] return self.dropout(X, **kwargs)Paddle 版(nn.Layer):
#@save class PositionalEncoding(nn.Layer): """位置编码""" def __init__(self, num_hiddens, dropout, max_len=1000): super(PositionalEncoding, self).__init__() self.dropout = nn.Dropout(dropout) # 创建一个足够长的P self.P = paddle.zeros((1, max_len, num_hiddens)) X = paddle.arange(max_len, dtype=paddle.float32).reshape( (-1, 1)) / paddle.pow(paddle.to_tensor([10000.0]), paddle.arange( 0, num_hiddens, 2, dtype=paddle.float32) / num_hiddens) self.P[:, :, 0::2] = paddle.sin(X) self.P[:, :, 1::2] = paddle.cos(X) def forward(self, X): X = X + self.P[:, :X.shape[1], :] return self.dropout(X)3.4 实现要点解读
四个版本逻辑完全一致,关键点有三处:
- 预计算一个足够长的 $\mathbf{P}$:构造
(1, max_len, num_hiddens)的矩阵,max_len默认 1000,覆盖最长序列;前向时用self.P[:, :X.shape[1], :]按当前序列长度切片,天然支持变长输入。 - 偶数维用正弦、奇数维用余弦:
X的形状是(max_len, num_hiddens/2),通过0::2与1::2的步进切片分别写入 $\sin$ 与 $\cos$ 值。 - 前向输出经 dropout:位置编码与词元嵌入相加后过 dropout 层,起到正则化作用;PyTorch/MXNet 版还分别用
.to(X.device)与.as_in_ctx(X.ctx)把预计算的 $\mathbf{P}$ 同步到输入张量所在的设备/上下文。
3.5 可视化:行是位置,列是编码维度
在位置嵌入矩阵 $\mathbf{P}$ 中,行代表词元在序列中的位置,列代表位置编码的不同维度。下面的代码实例化一个encoding_dim=32、num_steps=60的位置编码,绘制第 6~9 列的取值随位置变化的曲线:
# PyTorch 版 encoding_dim, num_steps = 32, 60 pos_encoding = PositionalEncoding(encoding_dim, 0) pos_encoding.eval() X = pos_encoding(d2l.zeros((1, num_steps, encoding_dim))) P = pos_encoding.P[:, :X.shape[1], :] d2l.plot(d2l.arange(num_steps), P[0, :, 6:10].T, xlabel='Row (position)', figsize=(6, 2.5), legend=["Col %d" % d for d in d2l.arange(6, 10)])从曲线中可以清楚地看到:第 6、7 列(对应更靠前的维度,$j$ 更小、频率更高)的波动频率明显高于第 8、9 列;第 6 列与第 7 列之间存在偏移(第 8、9 列同理),这正是正弦与余弦函数交替的结果。也就是说,编码维度越靠后,频率越低,位置编码由此在低维捕获细微位置差异、在高维捕获大尺度位置关系。
此外还可以用热图(heatmap)整体查看 $\mathbf{P}$:
# PyTorch 版 P = P[0, :, :].unsqueeze(0).unsqueeze(0) d2l.show_heatmaps(P, xlabel='Column (encoding dimension)', ylabel='Row (position)', figsize=(3.5, 4), cmap='Blues')热图同样印证了"沿编码维度频率单调降低"的结构。
四、位置编码的信息内涵
4.1 绝对位置信息:与二进制编码的类比
为了理解"沿编码维度单调降低的频率"如何携带绝对位置信息,可以先看看 $0,1,\ldots,7$ 的二进制表示:
for i in range(8): print(f'{i}的二进制是:{i:>03b}')输出为:
0的二进制是:000 1的二进制是:001 2的二进制是:010 3的二进制是:011 4的二进制是:100 5的二进制是:101 6的二进制是:110 7的二进制是:111可以观察到:在二进制中,第一个最低位(个位)在每 1 个数字上交替,第二个最低位在每 2 个数字上交替,第三个最低位在每 4 个数字上交替——较高比特位的交替频率低于较低比特位。位置编码正是沿用了这一思想,只是把离散的二进制比特替换为连续的三角函数:在编码维度上同样呈现"维度越靠后、频率越低"的递减结构。由于输出是浮点数,这种连续表示比二进制表示更节省空间,同时保留了不同位置向量的可分性。
4.2 相对位置信息:旋转矩阵的线性投影
除了绝对位置,位置编码还允许模型学习输入序列中的相对位置信息。原因在于:对任意确定的位置偏移 $\delta$,位置 $i+\delta$ 处的编码可以表示为位置 $i$ 处编码的线性投影。
令 $\omega_j = 1/10000^{2j/d}$。对于编码公式中的任意一对 $(p_{i, 2j}, p_{i, 2j+1})$,存在如下 $2 \times 2$ 投影关系:
$$\begin{bmatrix} \cos(\delta \omega_j) & \sin(\delta \omega_j) \ -\sin(\delta \omega_j) & \cos(\delta \omega_j) \end{bmatrix} \begin{bmatrix} p_{i, 2j} \ p_{i, 2j+1} \end{bmatrix} = \begin{bmatrix} p_{i+\delta, 2j} \ p_{i+\delta, 2j+1} \end{bmatrix}$$
推导过程利用了两角和公式:$\cos(\delta\omega_j)\sin(i\omega_j) + \sin(\delta\omega_j)\cos(i\omega_j) = \sin((i+\delta)\omega_j)$,$-\sin(\delta\omega_j)\sin(i\omega_j) + \cos(\delta\omega_j)\cos(i\omega_j) = \cos((i+\delta)\omega_j)$。
关键在于:这个 $2\times 2$ 投影矩阵(本质是一个旋转矩阵,其参数 $\delta\omega_j$)不依赖于任何位置索引 $i$。这意味着模型可以通过学习一组与位置无关的线性变换,从任意位置的编码推算出其偏移 $\delta$ 后的编码——这就是位置编码天然具备相对位置表达能力、且这一能力与序列长度无关的数学根源。
五、在 Transformer 中的实际应用与延伸
位置编码在仓库中并非孤立存在,它是 Transformer 编码器的标准组件。在 chapter_attention-mechanisms/transformer.md 的TransformerEncoder实现中(PyTorch 版见该文档对应代码块),可以看到它的典型用法:
self.pos_encoding = d2l.PositionalEncoding(num_hiddens, dropout) ... X = self.pos_encoding(self.embedding(X) * math.sqrt(self.num_hiddens))这里有两点值得注意:
- 嵌入缩放:固定位置编码的值范围在 $-1$ 到 $1$ 之间,因此学习得到的词元嵌入需要先乘以嵌入维度的平方根 $\sqrt{d}$ 进行重新缩放,再与位置编码相加,避免位置信号被词嵌入淹没。
- 模块复用:Transformer 编码器把
d2l.PositionalEncoding与堆叠的EncoderBlock组合使用,而EncoderBlock内部正是d2l.MultiHeadAttention——自注意力 + 位置编码共同构成了 Transformer 的核心骨架。仓库 d2l/torch.py 中的EncoderBlock源码也印证了这一点。
此外,自注意力与位置编码的组合在实际使用中还有几个常见的工程考量:固定正弦编码的max_len限制了可处理序列长度的上界(默认 1000);自注意力的 $\mathcal{O}(n^2)$ 注意力矩阵对长序列带来显存与算力压力;位置编码也可以改为可学习参数或相对位置偏置(Relative Position Bias)等变体。
六、小结
- 在自注意力中,查询、键和值都来自同一组输入,每个词元可以直接关注序列中的任意其他词元。
- 卷积神经网络与自注意力都拥有并行计算的优势,且自注意力的最大路径长度最短;但自注意力的计算复杂度是序列长度的二次方($\mathcal{O}(n^2d)$),在很长的序列上计算会非常慢。
- 为使用序列的顺序信息,可以在输入表示中添加位置编码,注入绝对或相对的位置信息;基于正弦/余弦的固定位置编码在编码维度上单调降低频率,既能区分绝对位置,又能通过与位置无关的线性投影表达相对位置。
- 仓库 d2l/ 工具包中,
MultiHeadAttention与PositionalEncoding均有 MXNet、PyTorch、TensorFlow、Paddle 四种框架的完整实现(分别见 d2l/mxnet.py、d2l/torch.py、d2l/tensorflow.py、d2l/paddle.py),可直接在 Transformer 等下游模型中复用。
练习与思考
- 假设设计一个深度架构,通过堆叠基于位置编码的自注意力层来表示序列,可能会存在哪些问题?(提示:考虑自注意力的二次方计算量、位置编码的绝对/相对信息在深层传播中的衰减,以及不加残差/归一化时的训练稳定性。)
- 请设计一种可学习的位置编码方法,并与固定正弦/余弦编码对比:可学习方案需要多少额外参数?它与固定编码在泛化到更长序列时的表现有何差异?
(本文基于《动手学深度学习》中文版仓库 chapter_attention-mechanisms/self-attention-and-positional-encoding.md 整理编写,相关实现细节以仓库源码为准。)
- 人工智能
- 深度学习
- 机器学习
- 教程
【免费下载链接】d2l-zh
《动手学深度学习》:面向中文读者、能运行、可讨论。中英文版被70多个国家的500多所大学用于教学。
相关推荐
如何用通达信缠论插件ChanlunX实现自动化技术分析
如何用通达信缠论插件ChanlunX实现自动化技术分析 如果你正在学习缠论技术分析,是否曾经为复杂的笔、段、中枢划分而头疼?每天花费数小时在K线图上手工划线,结
人工智能深度学习机器学习教程《动手学深度学习》Transformer 详解:从零实现基于自注意力的编码器-解码器架构
《动手学深度学习》Transformer 详解:从零实现基于自注意力的编码器 解码器架构 Transformer 是《动手学深度学习》(d2l zh)中注意力机
人工智能深度学习机器学习教程PaddlePaddle深度学习教程:自注意力机制与位置编码解析
PaddlePaddle深度学习教程:自注意力机制与位置编码解析 引言:为什么需要自注意力机制? 在传统的深度学习模型中,处理序列数据通常依赖于循环神经网络(R
文档教程人工智能深度学习
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考