文章目录
一.注意力机制
1.背景介绍
早在60年代就有了非参数的注意力机制Nadaraya-Watson核回归,使用核回归计算距离,作为权重;在数据足够的条件下可以拟合出原函数;
-
q q q为询问
-
( K i , V i ) (K_i,V_i) (Ki,Vi)为键值对,键值对之间在transformer中一般是相同的,也允许不同,但一定存在某种联系;更广义来说,三者的维度可以完全不一样
-
f ( q , ( k 1 , v 1 ) , … , ( k m , v m ) ) = ∑ i = 1 m α ( q , k i ) v i f(q,(k_1,v_1),\dots,(k_m,v_m))=\sum_{i=1}^{m} \alpha(q,k_i)v_i f(q,(k1,v1),…,(km,vm))=∑i=1mα(q,ki)vi
-
α ( q , k i ) = s o f t m a x ( a ( q , k i ) ) = e x p ( a ( q , k i ) ) ∑ j = 1 m e x p ( a ( q , k j ) ) \alpha(q,k_i)=softmax(a(q,k_i))=\frac{exp(a(q,k_i))}{\sum_{j=1}^{m}exp(a(q,k_j))} α(q,ki)=softmax(a(q,ki))=∑j=1mexp(a(q,kj))exp(a(q,ki)),称为注意力权重,也就是处于0~1之间,本质上是键对应的值的概率分布,概率小,选择该键值对的机会就小,对应得到更少的注意力。
其中的重点便是 a ( q , k i ) a(q,k_i) a(q,ki)的设计,称为注意力分数,也可以将其理解为二者间的关联度,当前可以采用两种方法: A d d i t i v e a t t e n i o n Additive\ attenion Additive attenion、 d o t − p r o d u c t a t t e n t i o n dot-product\ attention dot−product attention
加型注意力机制和点积注意力机制有着相同的计算复杂度,但点积注意力机制运算可以使用高度优化的并行矩阵乘法代码,会更快也更节省空间
2.Additive attenion(加型注意力机制)
-
一般来说,当查询和键是不同长度的矢量时,可以使用加性注意力作为评分函数
-
可学参数: h ∗ k h*k h∗k的矩阵 W k W_k Wk、 h ∗ q h*q h∗q的矩阵 W q W_q Wq、长为 h h h的向量 W W W
-
a ( q , k ) = W T t a n h ( W k k + W q q ) a(q,k)=W^T tanh(W_kk+W_qq) a(q,k)=WTtanh(Wkk+Wqq),tanh为激活函数;等价于将key和query合并起来后放入到一个隐藏大小为h,输出大小为1的单隐藏层MLP;h是隐藏单元数,是一个超参数。
class AdditiveAttention(nn.Module):
"""加性注意力"""
def __init__(self, key_size, query_size, num_hiddens, dropout, **kwargs):
super(AdditiveAttention, self).__init__(**kwargs)
self.W_k = nn.Linear(key_size, num_hiddens, bias=False)
self.W_q = nn.Linear(query_size, num_hiddens, bias=False)
self.w_v = nn.Linear(num_hiddens, 1, bias=False)
self.dropout = nn.Dropout(dropout)
def forward(self, queries, keys, values, valid_lens):
queries, keys = self.W_q(queries), self.W_k(keys)
# 通过unsqueeze在指定位置添加维度,实现维度扩展后,
# queries的形状:(batch_size,查询的个数,1,num_hidden)
# key的形状:(batch_size,1,“键-值”对的个数,num_hiddens)
# 选择在不同的位置添加新的维度是为了在加性注意力中正确地对齐查询和键,以便进行广播相加
# features 的形状为 (batch_size, num_queries, num_keys, num_hiddens)
features = queries.unsqueeze(2) + keys.unsqueeze(1)
features = torch.tanh(features)
# self.w_v线性变换仅有一个输出,因此从形状中移除最后那个维度。
# scores的形状:(batch_size,查询的个数,“键-值”对的个数)
scores = self.w_v(features).squeeze(-1)
self.attention_weights = masked_softmax(scores, valid_lens)
# values的形状:(batch_size,“键-值”对的个数,值的维度)
#最终的返回结果的形状:(batch_size,查询的个数,值的维度)
#通过加权求和,将所有键值对的加权结果合并成了最终的输出值
return torch.bmm(self.dropout(self.attention_weights), values)
masked_softmax
掩码Softmax操作的用处在于在处理序列数据时,对于某些位置的输入可能需要进行忽略或者特殊处理。通过使用掩码张量,可以将这些无效或特殊位置的权重设为负无穷大,从而在进行Softmax操作时,使得这些位置的输出为0。
这种操作通常在序列模型中使用,例如自然语言处理中的文本分类任务。在文本分类任务中,输入是一个句子或一个段落,长度可能不一致。为了保持输入的统一性,需要进行填充操作,使得所有输入的长度相同。然而,在经过填充操作后,一些位置可能对应于填充字符,这些位置的权重应该被忽略。通过使用掩码Softmax操作,可以确保填充位置的输出为0,从而在计算损失函数时不会对填充位置产生影响。
#queries有2个批量,每个批量有 1 个查询,查询向量的长度为 20
#keys有2 个批量,每个批量有 10 个键,每个键的向量长度为 2
queries, keys = torch.normal(0, 1, (2, 1, 20)), torch.ones((2, 10, 2))
# 创建了值矩阵 values,形状为 (2, 10, 4),表示有2个批量,每个批量有10个值,每个值的向量长度为 4
values = torch.arange(40, dtype=torch.float32).reshape(1, 10, 4).repeat(2, 1, 1)
valid_lens = torch.tensor([2, 6])
attention = AdditiveAttention(key_size=2, query_size=20, num_hiddens=8,
dropout=0.1)
#模型设置为评估模式,以保证在测试阶段不会应用 dropout 等训练阶段的操作
attention.eval()
attention(queries, keys, values, valid_lens)
tensor([[[ 2.0000, 3.0000, 4.0000, 5.0000]],
[[10.0000, 11.0000, 12.0000, 13.0000]]], grad_fn=<BmmBackward0>)
3.dot-product attention(点积型注意力机制)
-
当查询和键长度相同,可以用缩放点积注意力评分函数,仅含超参数Dropout
-
当key和query长度均为d时
a ( q , k ) = < q , k > d a(q,k)=\frac{<q,k>}{\sqrt{d}} a(q,k)=d<q,k>, d d d若过大,可能导致内积结果过大,除以 d \sqrt{d} d可以让它不会太大,也就是对长度不敏感
-
推广到批量询问: Q Q Q为 n ∗ d n*d n∗d的矩阵、 K K K为 m ∗ d m*d m∗d的矩阵、 V V V为 m ∗ v m*v m∗v的矩阵, n n n为批量数、 m m m为键值对个数
- a ( Q , K ) = Q K T d a(Q,K)=\frac{QK^T}{\sqrt{d}} a(Q,K)=dQKT,注意力分数为 n ∗ m n*m n∗m的矩阵
- f = s o f t m a x ( a ( Q , K ) ) V f=softmax(a(Q,K))V f=softmax(a(Q,K))V,最终的结果为 n ∗ v n*v n∗v的矩阵
class DotProductAttention(nn.Module):
"""缩放点积注意力"""
def __init__(self, dropout, **kwargs):
super(DotProductAttention, self).__init__(**kwargs)
self.dropout = nn.Dropout(dropout)
# queries的形状:(batch_size,查询的个数,d)
# keys的形状:(batch_size,“键-值”对的个数,d)
# values的形状:(batch_size,“键-值”对的个数,值的维度)
# valid_lens的形状:(batch_size,)或者(batch_size,查询的个数)
def forward(self, queries, keys, values, valid_lens=None):
d = queries.shape[-1]
# 设置transpose_b=True为了交换keys的最后两个维度
scores = torch.bmm(queries, keys.transpose(1,2)) / math.sqrt(d)
self.attention_weights = masked_softmax(scores, valid_lens)
return torch.bmm(self.dropout(self.attention_weights), values)
二.自注意力机制
- 给定序列 x 1 , … , x n , ∀ x i ∈ R d x_1,\dots,x_n,\forall x_i\in \mathbb{R}^d x1,…,xn,∀xi∈Rd
- 自注意力池化层将
x
i
x_i
xi作为query、key、value,来对序列抽取特征得到
y
1
,
…
,
y
n
y_1,\dots,y_n
y1,…,yn
- y i = f ( x i , ( x 1 , x 1 ) , … , ( x n , x n ) ) ∈ R d y_i=f(x_i,(x_1,x_1),\dots,(x_n,x_n))\in \mathbb{R}^d yi=f(xi,(x1,x1),…,(xn,xn))∈Rd
- x i x_i xi作为query, ( x j , x j ) (x_j,x_j) (xj,xj)作为使用的键值对,此时键值对的内容相同
- 完全并行,但对长序列的计算复杂度高
三.位置编码
-
在处理序列数据时,不同于CNN和RNN,自注意力并没有记录位置信息,为了使用序列的顺序信息,通过在输入表示中添加位置编码,来注入绝对的或相对的位置信息
-
位置编码将位置信息注入到输入里:假设长度为 n n n的序列是 X ∈ R n × d X\in \mathbb{R}^{n\times d} X∈Rn×d
-
使用位置编码矩阵 P ∈ R n × d P\in \mathbb{R}^{n\times d} P∈Rn×d,输出 X + P X+P X+P作为自编码输入
-
P P P中的元素根据列的奇偶差异,分为: p i , 2 j = sin ( i 1000 0 2 j d ) p_{i,2j}=\sin(\frac{i}{10000^{\frac{2j}{d}}}) pi,2j=sin(10000d2ji), p i , 2 j + 1 = cos ( i 1000 0 2 j d ) p_{i,2j+1}=\cos(\frac{i}{10000^{\frac{2j}{d}}}) pi,2j+1=cos(10000d2ji), j j j始于0
-
绝对位置信息:在上述公式中,频率 ω j = 1 1000 0 2 j d \omega_j=\frac{1}{10000^{\frac{2j}{d}}} ωj=10000d2j1,从函数定义中可以得出,频率沿向量维度减小, j j j越大,波长越长;不同词元的位置编码仅由其位置唯一决定
-
相对位置之间的线性关系:选择正弦余弦曲线函数,可以让模型更加轻易的学习关注相对位置信息
[ cos ( δ ω j ) sin ( δ ω j ) − sin ( δ ω j ) cos ( δ ω j ) ] [ p i , 2 j p i , 2 j + 1 ] = [ sin ( ( i + δ ) ω j ) cos ( ( i + δ ) ω j ) ] = [ p i + δ , 2 j p i + δ , 2 j + 1 ] \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}\sin((i+\delta)\omega_j)\\\cos((i+\delta)\omega_j)\end{bmatrix}=\begin{bmatrix}p_{i+\delta,2j}\\p_{i+\delta,2j+1}\end{bmatrix} [cos(δωj)−sin(δωj)sin(δωj)cos(δωj)][pi,2jpi,2j+1]=[sin((i+δ)ωj)cos((i+δ)ωj)]=[pi+δ,2jpi+δ,2j+1]
通过一个 2 × 2 2\times2 2×2的投影矩阵(与 i i i值无关),便可以实现线性投影,也可以理解为某个单词的位置信息是其他单词位置信息的线性组合,这种线性组合就意味着位置向量中蕴含了相对位置信息。
-
class PositionalEncoding(nn.Module):
"""位置编码"""
def __init__(self, num_hiddens, dropout, max_len=1000):
super(PositionalEncoding, self).__init__()
self.dropout = nn.Dropout(dropout)
# 创建一个足够长的P,batch_size为1
self.P = torch.zeros((1, max_len, num_hiddens))
X = torch.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):
#self.P第一个参数可通过广播,第二个参数由于输入序列的长度可能小于max_len,因此只取前X.shape[1]个位置的编码
#to(X.device)确保位置编码矩阵与输入数据张量位于同一设备上,以便执行张量相加操作,增加代码鲁棒性
X = X + self.P[:, :X.shape[1], :].to(X.device)
return self.dropout(X)
四.多头注意力机制
在实践中,当给定相同的查询、键和值的集合时, 我们希望模型可以基于相同的注意力机制学习到不同的行为, 然后将不同的行为作为知识组合起来, 捕获序列内各种范围的依赖关系 (例如,短距离依赖和长距离依赖关系)。
给定查询
q
∈
R
d
q
q\in \mathbb{R}^{d_q}
q∈Rdq、键
k
∈
R
d
k
k\in \mathbb{R}^{d_k}
k∈Rdk和值
v
∈
R
d
v
v\in \mathbb{R}^{d_v}
v∈Rdv,每个注意力头的计算方法为:
h
i
=
f
(
W
i
(
q
)
q
,
W
i
(
k
)
k
,
W
i
(
v
)
v
)
∈
R
p
v
h_i=f(W_i^{(q)}q,W_i^{(k)}k,W_i^{(v)}v)\in\mathbb{R}^{p_v}
hi=f(Wi(q)q,Wi(k)k,Wi(v)v)∈Rpv
多头注意力的输出需要经过另一个线性转换, 它对应着
h
h
h个头连结后的结果:
W
o
[
h
1
⋮
h
h
]
∈
R
p
v
W_o\begin{bmatrix}h_1\\\vdots\\h_h\end{bmatrix}\in \mathbb{R}^{p_v}
Wo
h1⋮hh
∈Rpv
- 其中可学习的参数包括: W i ( q ) ∈ R p q × d q W_i^{(q)}\in\mathbb{R}^{p_q\times d_q} Wi(q)∈Rpq×dq、 W i ( k ) ∈ R p k × d k W_i^{(k)}\in\mathbb{R}^{p_k\times d_k} Wi(k)∈Rpk×dk、 W i ( v ) ∈ R p v × d v W_i^{(v)}\in\mathbb{R}^{p_v\times d_v} Wi(v)∈Rpv×dv和 W o ∈ R p o × h p v W_o\in\mathbb{R}^{p_o\times hp_v} Wo∈Rpo×hpv
- 代表注意力汇聚的函数 f f f,可以是加性注意力、也可以是缩放点积注意力,不讨论涉及参数
- 参数说明
- num_hiddens对应上述的 p o p_o po,num_heads对应上述的 h h h
- num_hiddens/num_heads对应上述的 p q p_q pq、 p k p_k pk和 p v p_v pv
- 上述参数满足关系: p q = p k = p v = p o h p_q=p_k=p_v=\frac{p_o}{h} pq=pk=pv=hpo,但在实际代码中将查询、键和值的线性变换输出设置为 p q h = p k h = p v h = p o p_qh=p_kh=p_vh=p_o pqh=pkh=pvh=po,其目的是合并所有头的学习参数,再结合之后的DotProductAttention,实现整体的并行计算
- transpose_qkv作用说明
- 初始状态:(batch_size,查询或者“键-值”对的个数,num_hiddens)
- 变换结果:(batch_size*num_heads,查询或者“键-值”对的个数,num_hiddens/num_heads)
- 个人理解:理论中,每个头应当使用不同的 W ( q ) W^{(q)} W(q)、 W ( k ) W^{(k)} W(k)和 W ( v ) W^{(v)} W(v),但此时仅使用了一套,其实相当于把所有头的学习参数叠加在一起,然后借助transpose_qkv实现子空间划分,每个头能够独立地学习不同的查询、键和值的表示,从而实现多头注意力的效果。
class MultiHeadAttention(nn.Module):
"""多头注意力"""
def __init__(self, key_size, query_size, value_size, num_hiddens,
num_heads, dropout, bias=False, **kwargs):
super(MultiHeadAttention, self).__init__(**kwargs)
self.num_heads = num_heads
self.attention = d2l.DotProductAttention(dropout)
self.W_q = nn.Linear(query_size, num_hiddens, bias=bias)
self.W_k = nn.Linear(key_size, num_hiddens, bias=bias)
self.W_v = nn.Linear(value_size, num_hiddens, bias=bias)
self.W_o = nn.Linear(num_hiddens, num_hiddens, bias=bias)
def forward(self, queries, keys, values, valid_lens):
# queries,keys,values的初始形状:(batch_size,查询或者“键-值”对的个数,num_hiddens)
# 经过变换后,输出的queries,keys,values 的形状:
# (batch_size*num_heads,查询或者“键-值”对的个数,num_hiddens/num_heads)
queries = transpose_qkv(self.W_q(queries), self.num_heads)
keys = transpose_qkv(self.W_k(keys), self.num_heads)
values = transpose_qkv(self.W_v(values), self.num_heads)
# valid_lens 的形状:(batch_size,)或(batch_size,查询的个数)
if valid_lens is not None:
# 在轴0,将第一项(标量或者矢量)复制num_heads次,
# 然后如此复制第二项,然后诸如此类。
valid_lens = torch.repeat_interleave(
valid_lens, repeats=self.num_heads, dim=0)
output = self.attention(queries, keys, values, valid_lens)
# output的初始形状:(batch_size*num_heads,查询的个数,num_hiddens/num_heads)
# output_concat的形状:(batch_size,查询的个数,num_hiddens)
output_concat = transpose_output(output, self.num_heads)
return self.W_o(output_concat)
def transpose_qkv(X, num_heads):
"""为了多注意力头的并行计算而变换形状"""
# 输入X的形状:(batch_size,查询或者“键-值”对的个数,num_hiddens)
# 参数 -1 表示由函数自动计算该维度的大小,以保持原始张量的总大小不变
# 经过reshape输出X的形状:(batch_size,查询或者“键-值”对的个数,num_heads,num_hiddens/num_heads)
X = X.reshape(X.shape[0], X.shape[1], num_heads, -1)
#对变换后的张量X进行维度置换,将每个头的信息放在一起,以便并行计算
# 经过permute输出X的形状:(batch_size,num_heads,查询或者“键-值”对的个数,num_hiddens/num_heads)
X = X.permute(0, 2, 1, 3)
# 最终输出的形状:(batch_size*num_heads,查询或者“键-值”对的个数,num_hiddens/num_heads)
return X.reshape(-1, X.shape[2], X.shape[3])
def transpose_output(X, num_heads):
"""逆转transpose_qkv函数的操作"""
X = X.reshape(-1, num_heads, X.shape[1], X.shape[2])
X = X.permute(0, 2, 1, 3)
return X.reshape(X.shape[0], X.shape[1], -1)