Post

注意力机制

记录注意力机制相关知识,包括自注意力、Transformer、BERT、优化算法等

注意力机制

注意力机制(Attention Mechanism)

随意线索指的是和由主观意识产生的关注,不随意线索是潜意识产生的关注。注意力机制则是关注随意线索的,也可以叫做注意力池化。 根据query这个显式的随意线索寻找环境中类似的key-value。这里的key指的是环境中的随意线索

早期(60s)就有类似的注意力机制。非参注意力池化无需学习参数,直接根据键值对 $(x_i,y_i)$ 计算可得。其中 $K$ 为一个核,用于计算 $x$ 与 $x_i$ 之间的距离,转化为概率后再加权对应值 $y_i$,得到输出。

对于新输入query,直接根据给定的数据查找,类似KNN。根据 $(q,k)$ 相似程度,按比例去吸收 $y_i$ 的信息,有偏向性地选择输入。

核可以由很多种选择,如果选择高斯核 $K(u) = \frac{1}{\sqrt{2\pi}} \exp(-\frac{u^2}{2})$ ,那么Nadaraya-Watson 核回归即更新为:\(\begin{aligned} f(x) &= \sum_{i=1}^{n} \frac{\exp \left(-\frac{1}{2}(x - x_i)^2\right)}{\sum_{j=1}^{n} \exp \left(-\frac{1}{2}(x - x_j)^2\right)} y_i \\ &= \sum_{i=1}^{n} \operatorname{softmax}\left(-\frac{1}{2}(x - x_i)^2\right) y_i \end{aligned}\) 非参注意力优点是无需学习,但是缺点是,这种统计学的方法如果缺乏足够多的数据支持,通常会回归的不够准确以及平滑度不符。可以在之前的基础上引入可学习的 $w$ ,即带参数的注意力汇聚: \(f(x) = \sum_{i=1}^{n} \operatorname{softmax}\left(-\frac{1}{2}((x - x_i)\textbf{w})^2\right) y_i\) 注意力机制可以一般地写为:\(f(x) = \sum_{i} \alpha(x, x_i) y_i\) 这里 $\alpha(x, x_i)$ 为注意力权重,这个权重有多种设计方式。

注意力分数

由前面的注意力权重而来,未被标准化的为注意力分数,以及注意力机制的流程。

对于更高维度的情况,考虑向量输入,并且将键值对也考虑进函数 $f$ 中。$a(q,k_i)$ 为注意力分数。

注意力分数有两种设计方法,第一种是加性注意力。这种类型的好处是 key、value 和 query可以是不一样长度的。

第二个方法是key 和 query 同样长度下做内积,但是也没有需要像加性一样要学习的权重参数。 两种方法都可以实现高维注意力分数计算。

代码实现中,也需要和之前的零值化 sequence_masked 一样,对于无效的元素需要忽略,也就是遮蔽softmax。但是不能设置为0,需要设置为一个很小的数让 exp 之后的值接近于0来起到忽略的效果。

在加性注意力中,$q$ 和 $k$ 一般是不一样的,因此在计算注意力分数时需要进行向量化上升到4维,通过广播机制来使用矩阵完成计算。 假设现在有 $m$ 个 Query、$n$ 个 Key,需要计算 $m \times n$ 个组合。

  • queries 的形状是 (bs, m, h)
  • keys 的形状是 (bs, n, h) 将维度进行扩展,
    1
    2
    
    features = queries.unsqueeze(2) + keys.unsqueeze(1)
    features = torch.tanh(features)
    
  • queries.unsqueeze(2) 变成了 (bs, m, 1, h) ,把查询竖着排成一列,
  • keys.unsqueeze(1) 变成了 (bs, 1, n, h) ,把键横着排成一行。 最终广播求和后 features 的形状是 (bs, m, n, h) ,做到 $q$ 和 $k$ 和全对应。

cs231n中对注意力QKV和机制的更多解释: 之前我们提到过,原来的注意力层没有可学习的参数,也就是输入序列 $X$ 既用来计算相似度,也用来计算输出,这是两个本质上不一样的任务,一个用于匹配一个用于输出。

为了划分这两个本质不一样的任务,添加了key矩阵和value矩阵来让模型学习用于检索语义信息key空间($K = XW_K$)以及用于匹配真正输出语义value空间($V = XW_V$)。同样在后面还有Query空间($Q = XW_Q$)用于表示需要检索的信息。

在计算相似度时,分母的 $\sqrt{d_k}$ (方差,类似于一个标准化。维度越高,点积结果越大)用于控制尺度大小,避免 $E$ 过大导致梯度消失。

注意力层的直观表示:

使用注意力机制的Seq2Seq

原来的 Seq2Seq 中是将 encoder 的最后一个隐藏层(可能是一个句号)作为信息传递给 decoder 来进行序列翻译。但是生成的很多单词可能相关于源句子中不同的单词。

虽然需要的信息可能包含在最后隐藏状态层的历史信息中,但是想要依据非常复杂的信息集合生成出未知信息仍旧很困难。因此引入注意力机制,让模型关注翻译对应的词。所以不再始终让 encoder 的最后一层作为 decoder 的输入,而是根据上下文 context 的信息将前面对应的隐藏状态层信息结合 embedding 作为输入。

encoder 输出的词就是注意力机制的 key-value,decoder 进行预测时则使用之前预测词的输出作为 query 通过注意力输出 key-value结合下一个词的embedding 合并再作为输入。(见下图) 注意力机制可以根据解码器RNN的输出来匹配到合适的编码器的输出(两者处于同一语义空间下)来更有效地传递信息。

并且之前的Seq2Seq都是直接序列到序列的,加入注意力机制之后 context 一直在更新,所以需要回到一个一个预测的方法。可以看代码中的 forward 函数。

自注意力(Self-Attention)

自注意力就是将输入 $x_i$ 同时作为 key-value 和 query,只看自己。

自注意力:

自注意力机制不受排列顺序和位置影响,下图改变了 $X_1,X_2,X_3$ 的顺序。对应的 $Q,K,V$ 都没变。

和几个网络进行对比,自注意力不依赖于别的输出,并行度很高(完全并行),经常做较长的文本序列预测,但是计算量也比较大。

并且自注意力没有记录位置信息,输入的位置改变不会导致对应输出内容改变。对于位置信息没有直接放进编码器,而是将位置信息注入到输入里。

并且对于这样编码的位置信息,通过三角函数的表示可以使得每个位置的信息总是会有所偏差从而让模型能根据细微的差别来分辨不同的位置信息。其中行为位置(第 $i$ 个序列),列为不同的特征。 这样做的好处是不改变模型的大小,也不改变数据。但是对于模型的分辨能力,即被注入位置信息的输入中找到差别,有比较高的要求。

在计算机的二进制编码中,每个位置有一个绝对的位置信息,较高比特位的交替频率低于较低比特位,与下面的热图所示相似。 这样的位置编码也可以为序列中的每个元素分配到绝对唯一的位置信息。并且在位置信息的变换频率上呈现前快后慢的特点,但是每个位置编码始终是唯一的。

但是相比于绝对的位置信息,自注意力更加注重于相对的位置信息。这就回答了自注意力但是为什么要使用三角函数来表示?因为这样编码的是一个相对的位置信息。在一个句子或者序列当中,两个词/元素之间的相对位置 $\delta$ 包含的信息是比绝对位置 $i+\delta$ 包含的信息更多的。

Transformer

Transformer架构本质上还是一个编码器-解码器架构,但是基于纯注意力机制

Transfomer 块中加入了一些不同的组件。 Transfomer块中包含一个多头注意力(Multi-Head Attention),对于同一个qkv使用不同的注意力来提取不同局部权重的信息,最终再合并得到输出。 多头注意力数学上的表示,矩阵形状和变换:

和之前一样,解码器在训练和推理时不能考虑当前元素之后的元素。加入掩码(Mask) 来作为遮挡,其实就是前面提到的 validlen 的效果。

当前我们的输入形状为 $(batch_size,n,dim)$ ,$n$ 为序列长度。但是序列长度 n 是在变化的,不应该作为一个输入特征。 前馈网络PositionWiseFFN进行了维度变换,将序列中的每个 $x_i$ 都变成一个样本,等价于两个全连接层。 FFN还能被修改为MoE模型,包含E个专家模型。每次进来的A个token(A<E)会被Gate网络(计算Gate分数)路由到对应的专家模型,这样网络层的参数量变大E但是推理量只增加A。

为了做深,还加入了归一化层。但是这里使用的是 LayerNorm 而不是之前的 BatchNorm。 原因是:我们将输入的维度变为 $(bn,d)$ ,这里的序列长度始终在变化,而 BN 进行的归一化会因为变化的 $n$ 而不稳定,训练和预测的序列长度难以对应。

除此之外,在 NLP 任务中,同一个 Batch 里的句子长度往往参差不齐。为了并行,我们不得不使用 0 进行填充(Padding)。如果用 BN,这些无意义的 Padding 字符会极大地拉低均值和方差的统计值,导致真实词汇的特征被“带偏”。而 LN 针对的是单个词向量内部的归一化,它不关心 Batch 里其他句子长短,也不关心 Batch 大小,因此更加稳定。

BN是在不同样本(一个batch中中)的同一个维度进行的归一化,LN则是在同一个样本的不同维度进行归一化。这样在归一化时尽可能减小了序列长度(样本数)变化产生的影响。

中间的一个信息传递将编码器的输出作为了解码器中间过程的一个Transformer块的k-v,也就是普通的注意力机制。编码器和解码器的输出维度就是一样的,更加便捷和对称。

预测过程即为解码器的自注意力。

多头注意力代码实现中为了避免 for loop,需要对多头进行并行计算,通过定义的转置函数来实现。具体的实现细节为:

假设我们有以下输入:

  • batch_size = 2
  • 查询或”键-值”对个数 seq_len = 4
  • num_hiddens = 100
  • num_heads = 5
1
2
# 初始形状:
X.shape = (batch_size, seq_len, num_hiddens) = (2, 4, 100)

第一步:拆分维度

1
2
X = X.reshape(X.shape[0], X.shape[1], num_heads, -1)
X.shape = (2, 4, 5, 20)
  • 将 num_hiddens 维度拆分为 (num_heads, num_hiddens/num_heads) 即 (100,) → (5, 20)
  • 每个头获得 20 维的表示,获得了不同的局部信息,可以对应学习到该部分信息的权重。

第二步:维度重排

1
2
X = X.permute(0, 2, 1, 3)
X.shape = (2, 5, 4, 20)
  • 交换第1和第2个维度:(batch_size, seq_len, num_heads, ...) → (batch_size, num_heads, seq_len, ...),将 num_heads 维度移到 seq_len 之前

第三步:维度合并

1
2
return X.reshape(-1, X.shape[2], X.shape[3])
X.shape = (10, 4, 20)
  • 将 batch_size 和 num_heads 维度合并:(2, 5, 4, 20) → (2*5, 4, 20) = (10, 4, 20) 即变换为了 (batch_size*num_heads, seq_len, head_dim)

总结:

  • 编码器的自注意力中,qkv 来自于输入编码器的真实数据。
  • 解码器的自注意力中,qkv 来自于解码器先前预测的输出。
  • 编码器-解码器注意力中,q 来自于解码器先前的输出,kv 来自于编码器的输出。
  • Transformer 将这三者融合了起来。

在这里有个比较困惑的问题: 一开始没有弄清楚在训练和预测阶段中 qkv 是怎么在编码器自注意力、解码器自注意力和编码器-解码器注意力中作用的。

现在的理解是:训练阶段编码器是全部内容都可以看到的,有对应的 qkv 用于编码器的自注意力。并且其中的 kv 可以用于解码器内容预测过程。但是为了防止解码器偷看未来的信息,只学会“作弊”(因为训练过程source和target都有),需要通过掩码机制来遮蔽相对于解码器来说属于未来的信息。

但是在这个遮蔽训练的过程中,又陷入了之前 RNN 的串行计算思路。在 Tranformer 使用注意力的情况下,一个batch还是可以进行并行计算。可以通过一个下三角掩码矩阵(即掩码)来让一个batch可以并行计算不同序列位置的预测,也就是前向传播是并行的。但是在损失梯度反向传播的过程中,也就是参数更新时,一整个 batch 的损失会统一起来,对整个训练学习的参数权重产生影响。“一人犯错,全队受罚”。

BERT预训练与微调

在CV中,微调是基于已经预训练好的模型基础上根据需求再进行调整。BERT的动机也是类似的。BERT和之前的NLP任务相比模型更大,训练数据更多。

BERT的架构也比较简单,相当于只有编码器的 Transformer。提供了两个版本,也在 Transformer 的基础上对输入和Loss进行了改进。

对输入的修改:

  • 原本的 Transformer 编码器和解码器各有一个输入,source 和 target。现在只剩编码器之后将每个样本修改为了一个句子对
  • 加入了额外的片段embedding,给每个词添加了所属句段 <segment> 的信息 $E_A,E_B$。
  • 不再使用三角函数来计算位置编码,而是直接交给模型自己学习。

BERT的预训练有两个任务,即对Loss的修改: 单句任务 Masked Language Modeling (MLM)

给定一个句子,要求模型预测第 $t$ 个词,模型在训练时可以通过自注意力机制直接看到需要预测的词 $x_t$ ,这是一种信息泄露,那么模型就只能学会作弊。

BERT没有再采用单向掩码阻断的方法,而是在输入中挖掉目标词,替换为 <mask> 。这样模型就必须通过上下文来预测并且不会出现信息泄露的风险。从预测未来变为完形填空。

但是在下游的微调任务数据中永远不会出现 <mask> ,因此通过:

  • 80%概率替换为 <mask> ,进行基础训练。
  • 10%概率换为随机错误词,迫使模型必须时刻比较上下文。
  • 10%概率保持原来正确的词,允许偷看一点答案,让模型通过上下文进一步更好地提取最优秀的表征。

句子对任务Next Sentence Prediction (NSP) 然后需要预测一个句子中两个句子是否相邻,需要对训练样本进行不同的采样。这个任务借助了前面对句子的分段 <segment> ,因为对两个句子进行区分(二分类),一般使用0和1来表示。 从 <cls> 开始预测。

BERT微调

根据下游任务的不同,使用BERT微调也只需要修改输出层即可。且BERT对于每个token都抽取了上下文信息的特征,根据不同任务使用不同的特征。 常见的任务有:

  • 电影评价分类

  • 命名实体识别。区分Apple指的是可食用的苹果还是苹果公司。非特殊词元指非 <cls>, <sep> 是一种词级别的分类。

  • 根据文段回答问题。从描述文段中,根据问题提取文段中包含答案的部分。预测回答的开始、结尾或非回答,可以做成一个三分类问题(也可以更多)。

即使下游任务各有不同,但是BERT微调时均只需要增加符合目标任务需求的输出层

优化算法(Optimizer)

课件 包含详细解释。

梯度下降计算过程都是使用解析梯度(analytic gradient 一个函数表达式,通过计算图来表示)来求解,但是还会通过数值导数(numeric gradient 导数极限定义)来进行验证。 解析计算很快,数值计算很慢。

梯度下降优化算法的区分:

  • 全梯度下降(GD):对全部样本计算梯度然后求平均。但是计算速度很慢,对显存要求很高。
  • 随机梯度下降(SGD):从全部样本中随机抽取一个代表整体梯度。方向性不强,但是多次计算可以接近于全梯度的无偏估计。
  • 小批量随机梯度下降(MiniBatch SGD):随机抽取 batch_size 个样本作为批量 batch,对这个batch计算均值再梯度下降。方向性好一些,能充分发挥硬件并行计算的优势。

冲量法(Momentum) :通过过去的梯度 $\mathbf{g}_t$ 对当前的梯度 $\mathbf{v}_t$ 进行平滑,然后利用平滑过的梯度进行权重更新。

Adam 是一种更加平滑的优化算法,对学习率不敏感。 在 $t$ 比较小的时候(级数不够大时),权重和不为1,进行了如下 $\hat{\mathbf{v}}_t$ 对偏离趋势的修正,使得方向更加平滑。当t比较大时,$\hat{\mathbf{v}}_t$ 就趋近于0不产生影响了。 Adam还类似记录了 $s_t$ 历史方差信息,$\sqrt{\hat{\mathbf{s}}_t}$ 用于将梯度的各个维度值重新调整,拉到一个合适的范围内,得到修正后的梯度 $\mathbf{g}_t’$ 。

Adam更深入的理解见下cs231n:

在前面的动量法搭配SGD之后,再引入了一个均方根来根据每个维度中历史平方和(带衰减)对梯度进行元素级别的逐个缩放。 衰减率 $\rho$ 越小,$(1 - \rho)$ 越大,对于历史几乎没有记忆,$\text{grad_squared}$ 会被当前的梯度大小 $dx^2$ 强行主导。反之则对以往记忆能力较强。如果梯度突然变得非常大或者非常小,那么由于其在分母上,这会导致实际的步长被极度压缩或者放大,对于局部地形极度敏感

  • 衰减率越小,模型更加短视,更容易波动。
  • 衰减率越大,模型更加宏观,更稳定。这也是为什么后面的Adam将 beta2 默认设置为0.999. 最终这个优化器可以实现沿“陡峭”方向的进展会被抑制;沿“平坦”方向的进展会被加速。

将 momentum 和 RMSProp 结合起来就几乎是Adam了。但是需要加上一阶和二阶的偏置,防止从0开始的情况。 Adam这种基于历史梯度二阶矩(方差)在加入之前的权重衰减之后(直接在损失函数里面添加L2正则化项)效果完全不同。 像原来一样加上L2正则化后其衰减惩罚项同样会经历分母 $\sqrt{\hat{v}_t}$ 的自适应缩放,从而被削弱或者增强。但是权重衰减应该是无差别地限制所有参数的幅度来防止过拟合。

因此AdamW则是在adam更新了步长之后再独立对权重进行等比例的衰减。这样就绕过了二阶的缩放,并且通过解耦权重衰减和学习率两个超参数,能更好地进行超参数调优。

训练过程中学习率是一个很重要的超参数。学习率也可以根据实际情况进行调整。比较常用的是随着时间学习率逐步降低,这样可以做到开始时较快地收敛,并且不容易落入局部最优。 衰减的方式也有多种,余弦衰减、线性衰减、反比例曲线衰减等。

常用的优化器都是一阶优化,是通过求导数、做切线、走步长来实现的。如果使用二阶优化可以做到更快的收敛,但是需要通过海森矩阵 (Hessian) 表示损失函数的二阶导数信息,并通过牛顿法求解。这样会带来极大的计算复杂度,但是并没有显著优于一阶优化的收益。 因此对于数据量极大的深度学习来说,二阶优化并不常用。

This post is licensed under CC BY 4.0 by the author.