第04章:注意力机制(Attention,Softmax,位置编码器)#

1. 序列模型的挑战#

长距离依赖问题#

在自然语言处理中,序列模型面临的一个核心挑战是捕捉长距离依赖关系。长距离依赖是指序列中相距较远的元素之间存在的语义或语法关联。例如,在句子”我昨天在书店买的那本讲述人工智能历史的书非常有趣”中,”书”和”有趣”之间存在主谓关系,但它们在句子中相距较远。

传统的N-gram模型由于固定的上下文窗口大小,无法有效捕捉长距离依赖。即使是高阶的N-gram模型(如5-gram、6-gram),也只能考虑有限的上下文,而且随着N的增加,数据稀疏性问题会变得更加严重。

长距离依赖问题在多种自然语言现象中表现得尤为明显:

  1. 指代消解:代词与其指代对象之间可能相距很远。例如,”尽管张三认为自己已经尽力了,但李四仍然对他感到失望”中,”他”指代的是”张三”而非”李四”。

  2. 长期记忆:在长文本或对话中,早期提到的信息可能在很久之后才变得相关。例如,一篇小说的开头描述的场景可能在结尾处再次被提及。

  3. 语法一致性:在某些语言中,句子的不同部分需要保持语法一致,即使它们相距很远。例如,在英语中的主谓一致:”The cat, which was hiding under the table with all the other animals that had been frightened by the sudden noise, is now sleeping peacefully.”(这只猫,它曾躲在桌子下与所有其他被突然的噪音吓到的动物在一起,现在正安静地睡觉。)这里”cat”和”is”之间存在主谓一致关系,尽管它们被一个长的从句分隔。

  4. 逻辑推理:理解文本中的逻辑关系可能需要整合相距很远的信息。例如,在论证文章中,结论可能基于文章开头提出的前提。

捕捉这些长距离依赖关系对于构建高性能的语言模型至关重要,因为它们是语言理解和生成的核心要素。

RNN及其变体的局限性#

循环神经网络(Recurrent Neural Networks, RNN)是为处理序列数据而设计的神经网络架构。基本RNN的核心思想是在处理序列的每个位置时,不仅考虑当前的输入,还考虑之前的隐藏状态,从而理论上能够捕捉序列中的长距离依赖关系。

RNN的基本公式如下: $$h_t = f(W_{hh} h_{t-1} + W_{xh} x_t + b_h)$$ $$y_t = g(W_{hy} h_t + b_y)$$

其中,$h_t$是时间步t的隐藏状态,$x_t$是时间步t的输入,$y_t$是时间步t的输出,$W_{hh}$、$W_{xh}$和$W_{hy}$是权重矩阵,$b_h$和$b_y$是偏置向量,$f$和$g$是激活函数。

尽管RNN在理论上可以处理任意长度的序列和捕捉长距离依赖,但在实践中,它们面临几个严重的局限性:

  1. 梯度消失与梯度爆炸: 在反向传播过程中,梯度需要通过时间步骤反向传播(称为”通过时间的反向传播”,BPTT)。由于重复乘以相同的权重矩阵,梯度可能会随着时间步的增加而指数级地减小(梯度消失)或增大(梯度爆炸)。梯度消失使得网络难以学习长距离依赖,因为远距离的信号变得微不足道;梯度爆炸则可能导致训练不稳定。

  2. 信息瓶颈: 标准RNN的隐藏状态通常是一个固定维度的向量,它必须编码序列中所有相关的历史信息。这创造了一个信息瓶颈,特别是对于长序列,隐藏状态可能无法有效地存储所有必要的信息。

  3. 顺序计算的限制: RNN的计算是顺序的,即必须按照序列的顺序一步一步地计算,这限制了并行化的可能性,导致训练和推理速度较慢,特别是对于长序列。

为了解决这些问题,研究者提出了几种RNN的变体:

  1. 长短期记忆网络(Long Short-Term Memory, LSTM): LSTM引入了门控机制(输入门、遗忘门和输出门)和细胞状态,使网络能够选择性地记忆或遗忘信息,从而缓解梯度消失问题。LSTM的公式如下:

    $$f_t = \sigma(W_f \cdot [h_{t-1}, x_t] + b_f)$$ $$i_t = \sigma(W_i \cdot [h_{t-1}, x_t] + b_i)$$ $$\tilde{C}t = \tanh(W_C \cdot [h{t-1}, x_t] + b_C)$$ $$C_t = f_t * C_{t-1} + i_t * \tilde{C}t$$ $$o_t = \sigma(W_o \cdot [h{t-1}, x_t] + b_o)$$ $$h_t = o_t * \tanh(C_t)$$

    其中,$f_t$是遗忘门,$i_t$是输入门,$\tilde{C}_t$是候选细胞状态,$C_t$是细胞状态,$o_t$是输出门,$h_t$是隐藏状态。

  2. 门控循环单元(Gated Recurrent Unit, GRU): GRU是LSTM的简化版本,它合并了输入门和遗忘门为一个更新门,并将细胞状态和隐藏状态合并。GRU的计算效率通常高于LSTM,同时在许多任务上表现相当。GRU的公式如下:

    $$z_t = \sigma(W_z \cdot [h_{t-1}, x_t] + b_z)$$ $$r_t = \sigma(W_r \cdot [h_{t-1}, x_t] + b_r)$$ $$\tilde{h}t = \tanh(W_h \cdot [r_t * h{t-1}, x_t] + b_h)$$ $$h_t = (1 - z_t) * h_{t-1} + z_t * \tilde{h}_t$$

    其中,$z_t$是更新门,$r_t$是重置门,$\tilde{h}_t$是候选隐藏状态,$h_t$是隐藏状态。

  3. 双向RNN(Bidirectional RNN): 双向RNN使用两个独立的RNN,一个按正向处理序列,另一个按反向处理序列,然后将两者的输出合并。这使得网络能够同时考虑过去和未来的上下文,对于许多NLP任务(如命名实体识别、词性标注)非常有效。

尽管这些变体在一定程度上缓解了标准RNN的问题,但它们仍然面临信息瓶颈和顺序计算的限制。特别是对于非常长的序列,即使是LSTM和GRU也难以有效地捕捉长距离依赖关系。

这些局限性促使研究者寻找新的解决方案,最终导致了注意力机制的发展,它能够直接建立序列中任意位置之间的连接,从而更有效地处理长距离依赖问题。

2. 注意力机制基础#

注意力的直观理解#

注意力机制(Attention Mechanism)是受人类认知过程启发而设计的一种神经网络组件。在人类认知中,注意力允许我们在处理大量信息时,选择性地关注最相关的部分。例如,当阅读一篇长文章时,我们不会同等地处理每个词,而是会更关注那些对理解当前内容最重要的词或短语。

在神经网络中,注意力机制的核心思想是允许模型在处理序列数据时,动态地关注输入序列的不同部分。与RNN等传统序列模型不同,注意力机制不需要将所有历史信息压缩到一个固定大小的隐藏状态中,而是可以直接访问整个输入序列,并根据当前的需求选择性地关注相关部分。

注意力机制的直观理解可以通过以下类比来说明:

想象你正在翻译一个复杂的句子。传统的方法是先阅读整个源句子,然后尝试记住所有内容,最后一次性翻译出来。这类似于编码器-解码器架构中的RNN,它将整个源句子编码为一个固定大小的向量。

而使用注意力机制的方法则更像是:你先大致浏览一遍源句子,然后在翻译每个词时,都会回头查看源句子中最相关的部分。例如,在翻译”The cat sat on the mat”时,当你翻译”cat”这个词时,你会特别关注源句子中的”cat”;当翻译”mat”时,你会关注源句子中的”mat”。这样,无论句子多长,你都能准确地翻译每个部分,因为你可以直接访问源句子中的任何信息,而不必将所有内容都记在脑中。

注意力机制的这种特性使其特别适合处理长序列和捕捉长距离依赖关系,因为它可以直接建立序列中任意位置之间的连接,而不受序列长度的限制。

查询(Query)、键(Key)、值(Value)三元组#

现代注意力机制通常基于查询(Query)、键(Key)和值(Value)三元组来实现。这种框架最初由Vaswani等人在2017年的论文《Attention is All You Need》中提出,是Transformer架构的核心组件。

在这个框架中:

  1. 查询(Query):表示当前位置的需求或兴趣。在机器翻译的例子中,查询可以是目标语言中当前正在生成的词的表示。

  2. 键(Key):表示源序列中每个位置的特征或属性。键用于与查询进行匹配,以确定源序列中哪些位置与当前查询最相关。

  3. 值(Value):表示源序列中每个位置的内容或信息。一旦确定了相关性(通过查询和键的匹配),值就会被聚合以产生注意力的输出。

注意力机制的计算过程可以概括为以下步骤:

  1. 对于给定的查询,计算它与所有键的相似度或匹配度。

  2. 将这些相似度转换为权重(通常通过softmax函数),使它们的总和为1。

  3. 使用这些权重对值进行加权求和,得到注意力的输出。

形式化地,给定查询q、键集合K和值集合V,注意力输出可以表示为:

$$\text{Attention}(q, K, V) = \sum_{i} \text{weight}(q, K_i) \cdot V_i$$

其中,$\text{weight}(q, K_i)$是查询q与键$K_i$的匹配度转换后的权重。

在实际应用中,查询、键和值通常是通过对输入序列的不同线性变换得到的。例如,在自注意力(Self-Attention)机制中,查询、键和值都来自同一个序列,但经过不同的线性变换:

$$Q = X W_Q, \quad K = X W_K, \quad V = X W_V$$

其中,$X$是输入序列的表示,$W_Q$、$W_K$和$W_V$是可学习的权重矩阵。

查询-键-值框架的优势在于其灵活性和通用性。它可以适应各种类型的注意力机制,包括自注意力、交叉注意力(Cross-Attention)等,并且可以扩展到多头注意力(Multi-head Attention)等更复杂的形式。

注意力分数计算#

注意力分数是衡量查询与键之间相似度或匹配度的度量。它决定了在计算注意力输出时,每个值的权重。有几种常见的方法来计算注意力分数:

  1. 点积注意力(Dot-Product Attention): 最简单的形式是直接计算查询和键的点积: $$\text{score}(q, k) = q \cdot k$$

    点积注意力计算效率高,但当查询和键的维度较大时,点积的方差也会增大,可能导致softmax函数进入饱和区域,梯度变得极小。

  2. 缩放点积注意力(Scaled Dot-Product Attention): 为了解决点积注意力的方差问题,Transformer引入了缩放因子: $$\text{score}(q, k) = \frac{q \cdot k}{\sqrt{d_k}}$$

    其中,$d_k$是键的维度。这种缩放使得无论维度如何,点积的方差都保持在合理范围内。

  3. 加性注意力(Additive Attention): 也称为Bahdanau注意力,使用一个前馈神经网络来计算分数: $$\text{score}(q, k) = v^T \tanh(W_q q + W_k k)$$

    其中,$v$、$W_q$和$W_k$是可学习的参数。加性注意力在计算上比点积注意力更昂贵,但在某些情况下可能表现更好,特别是当查询和键的维度不同时。

  4. 乘性注意力(Multiplicative Attention): 使用一个权重矩阵来转换查询,然后与键计算点积: $$\text{score}(q, k) = q^T W k$$

    其中,$W$是一个可学习的权重矩阵。

  5. 基于余弦相似度的注意力: 使用余弦相似度来计算查询和键之间的相似度: $$\text{score}(q, k) = \frac{q \cdot k}{||q|| \cdot ||k||}$$

    这种方法对向量的长度不敏感,只关注方向的相似性。

在实际应用中,缩放点积注意力因其计算效率和良好的性能而被广泛采用,特别是在Transformer架构中。

一旦计算出注意力分数,通常会使用softmax函数将其转换为概率分布(权重),确保所有权重的总和为1:

$$\text{weight}(q, k) = \frac{\exp(\text{score}(q, k))}{\sum_{j} \exp(\text{score}(q, k_j))}$$

然后,这些权重用于对值进行加权求和,得到注意力的输出:

$$\text{Attention}(q, K, V) = \sum_{i} \text{weight}(q, k_i) \cdot v_i$$

在矩阵形式中,缩放点积注意力可以表示为:

$$\text{Attention}(Q, K, V) = \text{softmax}\left(\frac{QK^T}{\sqrt{d_k}}\right)V$$

其中,$Q$、$K$和$V$分别是查询、键和值的矩阵,每行对应一个位置的向量。

注意力分数的计算是注意力机制的核心,它决定了模型如何分配注意力,从而影响模型捕捉序列中依赖关系的能力。不同的注意力分数计算方法可能适合不同的任务和数据特性,选择合适的方法是设计有效注意力机制的关键。

3. Softmax函数详解#

Softmax的数学定义#

Softmax函数是深度学习中常用的一种激活函数,特别是在多分类问题和注意力机制中。它将一个实数向量转换为概率分布,使得每个元素都是正数,且所有元素的和为1。

给定一个实数向量 $z = (z_1, z_2, …, z_n)$,Softmax函数将其转换为概率向量 $\sigma(z) = (\sigma(z)_1, \sigma(z)_2, …, \sigma(z)_n)$,其中:

$$\sigma(z)i = \frac{e^{z_i}}{\sum{j=1}^{n} e^{z_j}}$$

Softmax函数的主要特性包括:

  1. 归一化:输出值的总和为1,可以解释为概率分布。

  2. 非线性:Softmax是一个非线性函数,能够捕捉输入之间的复杂关系。

  3. 单调性:如果 $z_i > z_j$,则 $\sigma(z)_i > \sigma(z)_j$,即保持输入的相对大小关系。

  4. 平滑性:Softmax是一个平滑函数,在所有点都可微,有利于梯度下降优化。

在注意力机制中,Softmax函数用于将注意力分数转换为注意力权重。例如,在缩放点积注意力中:

$$\text{Attention}(Q, K, V) = \text{softmax}\left(\frac{QK^T}{\sqrt{d_k}}\right)V$$

这里,Softmax函数确保每个位置的注意力权重是正数且总和为1,使得注意力机制可以解释为对值的加权平均。

数值稳定性考虑#

尽管Softmax函数在理论上定义明确,但在实际计算中可能面临数值稳定性问题,特别是当输入包含非常大或非常小的数值时。

主要的数值稳定性问题来自于指数函数的计算。当输入值非常大时,$e^{z_i}$ 可能导致溢出(overflow),即超出计算机的浮点数表示范围;当输入值非常小时,$e^{z_i}$ 可能接近于零,导致下溢(underflow)和精度损失。

为了解决这些问题,通常采用以下技巧来计算Softmax:

  1. 减去最大值:在计算指数之前,从所有输入中减去最大值。这不会改变Softmax的结果,因为:

    $$\sigma(z)i = \frac{e^{z_i}}{\sum{j=1}^{n} e^{z_j}} = \frac{e^{z_i - C}}{\sum_{j=1}^{n} e^{z_j - C}}$$

    其中,$C$ 是任意常数,通常选择 $C = \max_j z_j$。

    这种技巧可以防止溢出,因为最大的指数值现在是 $e^0 = 1$,而其他值都是 $e^{负数} < 1$。

  2. 使用对数空间:在某些情况下,特别是当需要计算Softmax的对数时(如交叉熵损失),可以直接在对数空间中计算,避免显式计算指数:

    $$\log(\sigma(z)i) = z_i - \log\left(\sum{j=1}^{n} e^{z_j}\right)$$

    使用减去最大值的技巧,这可以进一步写为:

    $$\log(\sigma(z)i) = (z_i - \max_j z_j) - \log\left(\sum{j=1}^{n} e^{z_j - \max_j z_j}\right)$$

  3. 使用专门的数值库:现代深度学习框架(如PyTorch、TensorFlow)通常提供了数值稳定的Softmax实现,内部已经考虑了这些稳定性问题。

在注意力机制中,数值稳定性尤为重要,因为注意力分数可能有很大的变化,特别是在序列很长或模型很深时。例如,在Transformer中,缩放因子 $\sqrt{d_k}$ 的引入部分是为了缓解这个问题,使得点积的方差保持在合理范围内,从而使Softmax函数工作在其敏感区域。

实现技巧#

除了上述数值稳定性考虑外,在实现Softmax函数和基于Softmax的注意力机制时,还有一些实用技巧:

  1. 向量化计算:使用矩阵运算而非循环来计算Softmax,这可以显著提高计算效率,特别是在GPU上。例如,在PyTorch中:

    def stable_softmax(x):
        # x: [batch_size, sequence_length, dim]
        x_max = torch.max(x, dim=-1, keepdim=True)[0]
        x = x - x_max  # 数值稳定性技巧
        exp_x = torch.exp(x)
        return exp_x / torch.sum(exp_x, dim=-1, keepdim=True)
    
  2. 注意力掩码(Attention Mask):在处理变长序列或需要防止某些位置相互关注时,可以使用掩码来修改Softmax的输入:

    def masked_softmax(x, mask):
        # x: [batch_size, sequence_length, sequence_length]
        # mask: [batch_size, sequence_length, sequence_length], 0表示掩码位置,1表示有效位置
        x_masked = x * mask - 1e9 * (1 - mask)  # 将掩码位置设为很大的负数
        return stable_softmax(x_masked)
    

    这里,掩码中的0对应的位置在Softmax输入中被设为很大的负数,使得Softmax输出接近于0,有效地”屏蔽”了这些位置。

  3. 温度参数(Temperature):有时候可以引入一个温度参数来控制Softmax的”锐利度”:

    $$\sigma(z/T)i = \frac{e^{z_i/T}}{\sum{j=1}^{n} e^{z_j/T}}$$

    其中,$T$ 是温度参数。较低的温度($T < 1$)使得分布更加集中(更接近于one-hot),较高的温度($T > 1$)使得分布更加平滑。这在某些应用中很有用,如知识蒸馏或强化学习中的探索-利用平衡。

  4. 梯度裁剪(Gradient Clipping):在训练过程中,Softmax的梯度可能变得很大,特别是当输入分布非常不均匀时。使用梯度裁剪可以防止梯度爆炸,稳定训练过程。

  5. 稀疏注意力(Sparse Attention):在某些情况下,特别是对于很长的序列,可能希望注意力权重是稀疏的,即只有少数几个位置有显著的权重。这可以通过各种方法实现,如Top-K Softmax(只保留最大的K个值)或使用Entmax(Softmax的一种稀疏化变体)。

  6. 批处理和并行化:在实现注意力机制时,充分利用批处理和并行计算可以显著提高效率。例如,在Transformer中,多头注意力可以并行计算,而不是顺序计算每个头。

这些实现技巧不仅可以提高计算效率和数值稳定性,还可以扩展Softmax和注意力机制的功能,使其适应各种不同的应用场景。

4. 位置编码器#

为什么需要位置信息#

在处理序列数据时,元素的位置信息通常是至关重要的。例如,在自然语言中,词的顺序直接影响句子的含义。考虑以下两个句子:

  • “狗咬了人”

  • “人咬了狗”

这两个句子包含相同的词,但由于词序不同,它们的含义完全不同。因此,序列模型需要某种方式来编码元素的位置信息。

在RNN等传统序列模型中,位置信息是隐式编码的,因为RNN按顺序处理输入,当前时间步的隐藏状态依赖于之前所有时间步的信息。然而,在Transformer等基于注意力的模型中,情况有所不同。

Transformer的核心组件——自注意力机制是”置换不变的”(permutation invariant),这意味着如果我们改变输入序列中元素的顺序,但保持查询-键-值的对应关系不变,那么自注意力的输出将保持不变。换句话说,自注意力本身不考虑元素的位置,只关注元素之间的关系。

这种置换不变性在某些任务中可能是有益的(如集合处理),但对于大多数序列处理任务(如自然语言处理)来说,这是一个严重的限制。为了解决这个问题,Transformer引入了位置编码(Positional Encoding),显式地将位置信息注入到模型中。

位置编码的目标是为序列中的每个位置创建一个唯一的表示,使得模型能够区分不同位置的元素,同时保持位置之间的相对关系。理想的位置编码应该具有以下特性:

  1. 唯一性:每个位置都有一个唯一的编码。

  2. 确定性:相同位置的编码应该是一致的。

  3. 有界性:编码的范围应该是有界的,以便与词嵌入兼容。

  4. 距离感知:编码应该能够反映位置之间的距离,使得模型能够感知元素之间的相对位置。

  5. 可扩展性:编码方案应该能够处理任意长度的序列,包括训练中未见过的长度。

正弦余弦位置编码#

Transformer原始论文中提出的位置编码是基于正弦和余弦函数的。这种编码方法不需要学习,而是使用预定义的数学函数来生成位置向量。

具体来说,对于位置 $pos$ 和维度 $i$,位置编码 $PE$ 定义为:

$$PE_{(pos, 2i)} = \sin\left(\frac{pos}{10000^{2i/d_{model}}}\right)$$ $$PE_{(pos, 2i+1)} = \cos\left(\frac{pos}{10000^{2i/d_{model}}}\right)$$

其中,$d_{model}$ 是模型的维度,$i$ 的范围是 $[0, d_{model}/2)$。

这种编码方法有几个重要特性:

  1. 唯一性:每个位置都有一个唯一的编码向量。

  2. 有界性:所有编码值都在 $[-1, 1]$ 范围内。

  3. 距离感知:编码中包含了不同频率的正弦波,使得模型能够感知不同尺度的相对位置。特别地,对于任何固定的偏移量 $k$,$PE_{pos+k}$ 可以表示为 $PE_{pos}$ 的线性函数,这使得模型更容易学习相对位置关系。

  4. 可扩展性:这种编码方法可以扩展到任意长度的序列,即使是训练中未见过的长度。

正弦余弦位置编码的实现非常简单:

import numpy as np

def positional_encoding(max_seq_length, d_model):
    # 创建一个 [max_seq_length, d_model] 的零矩阵
    pe = np.zeros((max_seq_length, d_model))
    
    # 计算位置编码
    for pos in range(max_seq_length):
        for i in range(0, d_model, 2):
            pe[pos, i] = np.sin(pos / (10000 ** (i / d_model)))
            if i + 1 < d_model:
                pe[pos, i + 1] = np.cos(pos / (10000 ** (i / d_model)))
    
    return pe

在Transformer中,位置编码通常直接加到词嵌入上:

$$\text{input} = \text{embedding} + \text{positional_encoding}$$

这样,自注意力机制就能够同时考虑词的语义信息和位置信息。

可学习位置编码#

除了使用预定义的正弦余弦函数,另一种常见的方法是使用可学习的位置编码。在这种方法中,位置编码是模型的可学习参数,通过训练过程来优化。

可学习位置编码的实现非常简单:

import torch.nn as nn

class LearnablePositionalEncoding(nn.Module):
    def __init__(self, max_seq_length, d_model):
        super(LearnablePositionalEncoding, self).__init__()
        self.positional_encoding = nn.Parameter(torch.zeros(max_seq_length, d_model))
        nn.init.normal_(self.positional_encoding, mean=0, std=0.02)
    
    def forward(self, x):
        # x: [batch_size, seq_length, d_model]
        seq_length = x.size(1)
        return x + self.positional_encoding[:seq_length, :]

可学习位置编码相比正弦余弦位置编码有以下优缺点:

优点

  1. 灵活性:可以适应特定任务和数据集的需求。

  2. 潜在的性能提升:在某些任务上可能表现更好,因为编码是针对任务优化的。

缺点

  1. 有限的序列长度:只能处理不超过训练时设定的最大序列长度的序列。

  2. 需要更多的训练数据:增加了模型的参数数量,可能需要更多的训练数据来有效学习。

  3. 可能的过拟合:如果训练数据不足,可能导致过拟合。

在实践中,两种方法都被广泛使用,选择哪种方法通常取决于具体任务和可用的计算资源。一些研究表明,在许多任务上,两种方法的性能差异不大。

除了上述两种基本方法,还有一些变体和改进,如相对位置编码(Relative Positional Encoding)、旋转位置编码(Rotary Position Embedding, RoPE)等,它们在某些任务或模型架构中可能表现更好。

5. 实现自注意力机制#

自注意力层的前向传播#

自注意力(Self-Attention)是注意力机制的一种特殊形式,其中查询、键和值都来自同一个序列。它允许序列中的每个位置关注序列中的所有位置,从而捕捉序列内部的依赖关系。

自注意力层的前向传播过程可以分为以下几个步骤:

  1. 线性投影:将输入序列 $X$ 通过三个不同的线性变换,得到查询 $Q$、键 $K$ 和值 $V$: $$Q = XW_Q, \quad K = XW_K, \quad V = XW_V$$ 其中,$W_Q$、$W_K$ 和 $W_V$ 是可学习的权重矩阵。

  2. 计算注意力分数:使用查询和键计算注意力分数。在Transformer中,使用缩放点积注意力: $$\text{Scores} = \frac{QK^T}{\sqrt{d_k}}$$ 其中,$d_k$ 是键的维度。

  3. 应用掩码(可选):如果需要防止某些位置相互关注(如在解码器中防止关注未来位置),可以应用掩码: $$\text{Masked_Scores} = \text{Scores} + \text{Mask}$$ 其中,$\text{Mask}$ 是一个包含很大负数(如 $-10^9$)的矩阵,对应于需要掩码的位置。

  4. 计算注意力权重:使用Softmax函数将分数转换为权重: $$\text{Weights} = \text{softmax}(\text{Masked_Scores})$$

  5. 加权求和:使用注意力权重对值进行加权求和,得到注意力输出: $$\text{Output} = \text{Weights} \cdot V$$

  6. 线性变换和残差连接(可选):在Transformer中,通常会对注意力输出进行一个额外的线性变换,然后添加残差连接: $$\text{Final_Output} = \text{LayerNorm}(X + \text{Dropout}(\text{Output} \cdot W_O))$$ 其中,$W_O$ 是可学习的权重矩阵,$\text{LayerNorm}$ 是层归一化,$\text{Dropout}$ 是dropout正则化。

下面是一个使用PyTorch实现自注意力层的示例代码:

import torch
import torch.nn as nn
import torch.nn.functional as F
import math

class SelfAttention(nn.Module):
    def __init__(self, d_model, dropout=0.1):
        super(SelfAttention, self).__init__()
        self.d_model = d_model
        self.query = nn.Linear(d_model, d_model)
        self.key = nn.Linear(d_model, d_model)
        self.value = nn.Linear(d_model, d_model)
        self.dropout = nn.Dropout(dropout)
        
    def forward(self, x, mask=None):
        # x: [batch_size, seq_length, d_model]
        batch_size, seq_length, d_model = x.size()
        
        # 线性投影
        q = self.query(x)  # [batch_size, seq_length, d_model]
        k = self.key(x)    # [batch_size, seq_length, d_model]
        v = self.value(x)  # [batch_size, seq_length, d_model]
        
        # 计算注意力分数
        scores = torch.matmul(q, k.transpose(-2, -1)) / math.sqrt(d_model)
        # scores: [batch_size, seq_length, seq_length]
        
        # 应用掩码(可选)
        if mask is not None:
            scores = scores.masked_fill(mask == 0, -1e9)
        
        # 计算注意力权重
        weights = F.softmax(scores, dim=-1)
        weights = self.dropout(weights)
        # weights: [batch_size, seq_length, seq_length]
        
        # 加权求和
        output = torch.matmul(weights, v)
        # output: [batch_size, seq_length, d_model]
        
        return output, weights

这个实现包含了自注意力的核心步骤,但省略了一些Transformer中的细节,如多头注意力、残差连接和层归一化。

自注意力层的反向传播#

自注意力层的反向传播是通过自动微分系统(如PyTorch的autograd)自动计算的,但理解其数学原理有助于深入理解注意力机制。

反向传播的目标是计算损失函数 $L$ 关于自注意力层参数的梯度,主要包括 $W_Q$、$W_K$ 和 $W_V$。根据链式法则,我们需要首先计算 $L$ 关于层输出 $\text{Output}$ 的梯度 $\frac{\partial L}{\partial \text{Output}}$,然后反向传播到各个参数。

以下是自注意力层反向传播的主要步骤:

  1. 计算 $L$ 关于 $\text{Output}$ 的梯度: 这通常由上层传递下来,记为 $\frac{\partial L}{\partial \text{Output}}$。

  2. 计算 $L$ 关于 $\text{Weights}$ 和 $V$ 的梯度: $$\frac{\partial L}{\partial \text{Weights}} = \frac{\partial L}{\partial \text{Output}} \cdot V^T$$ $$\frac{\partial L}{\partial V} = \text{Weights}^T \cdot \frac{\partial L}{\partial \text{Output}}$$

  3. 计算 $L$ 关于 $\text{Scores}$ 的梯度: 这涉及到Softmax函数的导数,可以表示为: $$\frac{\partial L}{\partial \text{Scores}} = \frac{\partial L}{\partial \text{Weights}} \odot \frac{\partial \text{Weights}}{\partial \text{Scores}}$$ 其中,$\odot$ 表示Hadamard积(元素wise乘法),$\frac{\partial \text{Weights}}{\partial \text{Scores}}$ 是Softmax函数的雅可比矩阵。

  4. 计算 $L$ 关于 $Q$ 和 $K$ 的梯度: $$\frac{\partial L}{\partial Q} = \frac{\partial L}{\partial \text{Scores}} \cdot \frac{K}{\sqrt{d_k}}$$ $$\frac{\partial L}{\partial K} = \frac{\partial L}{\partial \text{Scores}}^T \cdot \frac{Q}{\sqrt{d_k}}$$

  5. 计算 $L$ 关于 $W_Q$、$W_K$ 和 $W_V$ 的梯度: $$\frac{\partial L}{\partial W_Q} = X^T \cdot \frac{\partial L}{\partial Q}$$ $$\frac{\partial L}{\partial W_K} = X^T \cdot \frac{\partial L}{\partial K}$$ $$\frac{\partial L}{\partial W_V} = X^T \cdot \frac{\partial L}{\partial V}$$

  6. 计算 $L$ 关于输入 $X$ 的梯度: $$\frac{\partial L}{\partial X} = \frac{\partial L}{\partial Q} \cdot W_Q^T + \frac{\partial L}{\partial K} \cdot W_K^T + \frac{\partial L}{\partial V} \cdot W_V^T$$

这些梯度计算通常由深度学习框架自动处理,但理解这个过程有助于调试和优化模型。

案例:使用自注意力处理序列数据#

下面我们将展示一个使用自注意力机制处理序列数据的完整示例。我们将实现一个简单的文本分类模型,使用自注意力来捕捉句子中词之间的依赖关系。

import torch
import torch.nn as nn
import torch.optim as optim
import torch.nn.functional as F
import numpy as np
from torch.utils.data import Dataset, DataLoader

# 自注意力层
class SelfAttention(nn.Module):
    def __init__(self, d_model, dropout=0.1):
        super(SelfAttention, self).__init__()
        self.d_model = d_model
        self.query = nn.Linear(d_model, d_model)
        self.key = nn.Linear(d_model, d_model)
        self.value = nn.Linear(d_model, d_model)
        self.dropout = nn.Dropout(dropout)
        
    def forward(self, x, mask=None):
        batch_size, seq_length, d_model = x.size()
        
        q = self.query(x)
        k = self.key(x)
        v = self.value(x)
        
        scores = torch.matmul(q, k.transpose(-2, -1)) / torch.sqrt(torch.tensor(d_model, dtype=torch.float32))
        
        if mask is not None:
            scores = scores.masked_fill(mask == 0, -1e9)
        
        weights = F.softmax(scores, dim=-1)
        weights = self.dropout(weights)
        
        output = torch.matmul(weights, v)
        
        return output, weights

# 位置编码
class PositionalEncoding(nn.Module):
    def __init__(self, d_model, max_seq_length=5000):
        super(PositionalEncoding, self).__init__()
        
        pe = torch.zeros(max_seq_length, d_model)
        position = torch.arange(0, max_seq_length, 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)
        self.register_buffer('pe', pe)
        
    def forward(self, x):
        return x + self.pe[:, :x.size(1), :]

# 文本分类模型
class TextClassifier(nn.Module):
    def __init__(self, vocab_size, d_model, num_classes, max_seq_length, dropout=0.1):
        super(TextClassifier, self).__init__()
        self.embedding = nn.Embedding(vocab_size, d_model)
        self.positional_encoding = PositionalEncoding(d_model, max_seq_length)
        self.attention = SelfAttention(d_model, dropout)
        self.fc = nn.Linear(d_model, num_classes)
        self.dropout = nn.Dropout(dropout)
        
    def forward(self, x, mask=None):
        # x: [batch_size, seq_length]
        embedded = self.embedding(x)  # [batch_size, seq_length, d_model]
        embedded = self.positional_encoding(embedded)
        
        attended, weights = self.attention(embedded, mask)
        
        # 全局平均池化
        pooled = attended.mean(dim=1)  # [batch_size, d_model]
        
        output = self.fc(self.dropout(pooled))  # [batch_size, num_classes]
        
        return output, weights

# 示例数据集
class TextDataset(Dataset):
    def __init__(self, texts, labels, vocab, max_length):
        self.texts = texts
        self.labels = labels
        self.vocab = vocab
        self.max_length = max_length
        
    def __len__(self):
        return len(self.texts)
    
    def __getitem__(self, idx):
        text = self.texts[idx]
        label = self.labels[idx]
        
        # 将文本转换为索引
        tokens = text.split()
        indices = [self.vocab.get(token, self.vocab['<UNK>']) for token in tokens]
        
        # 填充或截断到固定长度
        if len(indices) < self.max_length:
            indices = indices + [self.vocab['<PAD>']] * (self.max_length - len(indices))
        else:
            indices = indices[:self.max_length]
        
        return torch.tensor(indices), torch.tensor(label)

# 创建一个简单的词汇表和数据集
def create_sample_data():
    texts = [
        "i love this movie",
        "this movie is great",
        "the movie was boring",
        "i hate this film",
        "this is the worst movie ever"
    ]
    labels = [1, 1, 0, 0, 0]  # 1表示正面评价,0表示负面评价
    
    # 创建词汇表
    vocab = {'<PAD>': 0, '<UNK>': 1}
    for text in texts:
        for token in text.split():
            if token not in vocab:
                vocab[token] = len(vocab)
    
    return texts, labels, vocab

# 训练函数
def train(model, dataloader, criterion, optimizer, device):
    model.train()
    total_loss = 0
    
    for batch in dataloader:
        inputs, targets = batch
        inputs, targets = inputs.to(device), targets.to(device)
        
        # 创建掩码
        mask = (inputs != 0).unsqueeze(-1).expand(-1, -1, inputs.size(1))
        mask = mask & mask.transpose(-2, -1)
        
        optimizer.zero_grad()
        outputs, _ = model(inputs, mask)
        loss = criterion(outputs, targets)
        loss.backward()
        optimizer.step()
        
        total_loss += loss.item()
    
    return total_loss / len(dataloader)

# 主函数
def main():
    # 参数设置
    d_model = 64
    num_classes = 2
    max_seq_length = 10
    batch_size = 2
    num_epochs = 100
    learning_rate = 0.001
    
    # 创建数据
    texts, labels, vocab = create_sample_data()
    dataset = TextDataset(texts, labels, vocab, max_seq_length)
    dataloader = DataLoader(dataset, batch_size=batch_size, shuffle=True)
    
    # 创建模型
    device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
    model = TextClassifier(len(vocab), d_model, num_classes, max_seq_length)
    model.to(device)
    
    # 损失函数和优化器
    criterion = nn.CrossEntropyLoss()
    optimizer = optim.Adam(model.parameters(), lr=learning_rate)
    
    # 训练模型
    for epoch in range(num_epochs):
        loss = train(model, dataloader, criterion, optimizer, device)
        if (epoch + 1) % 10 == 0:
            print(f'Epoch {epoch+1}/{num_epochs}, Loss: {loss:.4f}')
    
    # 可视化注意力权重
    model.eval()
    with torch.no_grad():
        for i, (text, label) in enumerate(dataset):
            text = text.unsqueeze(0).to(device)
            mask = (text != 0).unsqueeze(-1).expand(-1, -1, text.size(1))
            mask = mask & mask.transpose(-2, -1)
            
            _, attention_weights = model(text, mask)
            
            print(f"Text: {texts[i]}")
            print(f"Label: {'Positive' if labels[i] == 1 else 'Negative'}")
            print("Attention Weights:")
            print(attention_weights[0].cpu().numpy())
            print()

if __name__ == "__main__":
    main()

这个示例展示了如何使用自注意力机制处理文本分类任务。模型首先将输入文本转换为词嵌入,然后添加位置编码,接着使用自注意力层捕捉词之间的依赖关系,最后通过全连接层进行分类。

通过可视化注意力权重,我们可以看到模型如何关注输入序列的不同部分,这提供了模型决策过程的可解释性。例如,在积极评价中,模型可能会更关注”love”、”great”等积极词汇;在消极评价中,则可能更关注”boring”、”hate”、”worst”等消极词汇。

这个简单的例子展示了自注意力机制的强大之处:它能够动态地关注输入序列的不同部分,捕捉复杂的依赖关系,而不受固定窗口大小的限制。这使得基于注意力的模型在处理序列数据,特别是长序列数据时,表现出色。

总结#

在本章中,我们深入探讨了注意力机制,这是现代语言模型的核心组件之一。我们首先分析了序列模型面临的挑战,特别是长距离依赖问题,以及RNN及其变体的局限性。然后,我们介绍了注意力机制的基础概念,包括查询-键-值三元组和注意力分数计算方法。

我们详细讲解了Softmax函数,它在注意力机制中用于将分数转换为权重。我们讨论了Softmax的数学定义、数值稳定性考虑和实现技巧。接着,我们探讨了位置编码的重要性,介绍了正弦余弦位置编码和可学习位置编码两种常见方法。

最后,我们实现了自注意力机制,包括前向传播和反向传播过程,并通过一个文本分类的案例展示了如何使用自注意力处理序列数据。

注意力机制的引入彻底改变了序列模型的设计范式,使模型能够直接建立序列中任意位置之间的连接,有效地处理长距离依赖问题。它是Transformer架构的核心组件,也是GPT、BERT等现代语言模型的基础。

在下一章中,我们将学习Transformer架构,它基于注意力机制构建,并引入了多头注意力、残差连接和层归一化等重要组件,是现代语言模型的基础架构。