Part1:掩码张量
什么是掩码张量:
- 掩代表遮掩,码就是我们张量中的数值,它的尺⼨不定,⾥⾯⼀般只有1和0的元素,代表位置被遮掩或者不被遮掩,⾄于是0位置被遮掩还是1位置被遮掩可以⾃定义,因此它的作⽤就是让另外⼀个张量中的⼀些数值被遮掩,也可以说被替换, 它的表现形式是⼀个张量。
掩码张量的作⽤:
- 在transformer中, 掩码张量的主要作⽤在应⽤attention时,有⼀些⽣成的attention张量中的值计算有可能是已知未来信息⽽得到的,未来信息被看到是因为训练时会把整个输出结果都⼀次性进⾏Embedding,但是理论上解码器的的输出却不是⼀次就能产⽣最终结果的,⽽是⼀次次通过上⼀次结果综合得出的,因此,未来的信息可能被提前利⽤. 造成训练出的模型性能不好,所以,需要对信息进行遮掩。
⽣成掩码张量:
def subsequent_mask(size):
# ⽣成向后遮掩的掩码张量, 参数size是掩码张量最后两个维度的⼤⼩,
#它的最后两维形成⼀个⽅阵
# 定义掩码张量的形状
attn_shape = (1, size, size)
# 然后使⽤np.ones⽅法向这个形状中添加1元素,形成上三⻆阵, 最后为了节约空间,
# 再使其中的数据类型变为⽆符号8位整形unit8
subsequent_mask = np.triu(np.ones(attn_shape), k=1).astype('uint8')
# 最后将numpy类型转化为torch中的tensor, 内部做⼀个1 - 的操作,
# 在这个其实是做了⼀个三⻆阵的反转, subsequent_mask中的每个元素都会被1减,
# 如果是0, subsequent_mask中的该位置由0变成1
# 如果是1, subsequent_mask中的该位置由1变成0
return torch.from_numpy(1 - subsequent_mask)
np.triu()函数效果:
print(np.triu([[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]], k=1))
print(np.triu([[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]], k=0))
print(np.triu([[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]], k=-1))
掩码张量使用实例:
size = 5
masked = subsequent_mask(size)
print(masked)
plt.figure(figsize=(5, 5))
plt.imshow(subsequent_mask(20)[0])
plt.show()
masked的输出为:
效果如图所示:
效果分析:
- 通过观察可视化⽅阵, ⻩⾊是1的部分, 这⾥代表被遮掩, 紫⾊代表没有被遮掩的信息,横坐标代表⽬标词汇的位置, 纵坐标代表可查看的位置;
- 我们看到, 在0的位置我们⼀看望过去都是⻩⾊的, 都被遮住了,1的位置⼀眼望过去还是⻩⾊, 说明第⼀次词还没有产⽣, 从第⼆个位置看过去, 就能看到位置1的词, 其他位置看不到, 以此类推。
Part2:注意⼒机制
什么是注意⼒:
- 我们观察事物时,之所以能够快速判断⼀种事物(当然允许判断是错误的), 是因为我们脑能够很快把注意力放在事物最具有辨识度的部分从⽽作出判断,⽽并⾮是从头到尾的观察⼀遍事物后,才能有判断结果. 正是基于这样的理论,就产生了注意⼒机制。换句话来说:它模仿了人类处理信息的方式。在处理大量信息时,人类的大脑并不会平等对待所有信息,而是会选择性地关注某些部分,忽略其他部分。这种选择性的关注机制使得大脑能够高效地处理复杂的信息,避免信息过载。
什么是注意⼒计算规则:
- 它需要三个指定的输⼊Q(query), K(key), V(value), 然后通过公式得到注意⼒的计算结果,这个结果代表query在key和value作⽤下的表示。
Q, K, V的⽐喻解释:
- 假如我们有⼀个问题: 给出⼀段⽂本,使⽤⼀些关键词对它进行描述!为了⽅便统⼀正确答案,这道题可能预先已经给⼤家写出了⼀些关键词作为提示.其中这些给出的提示就可以看作是key,⽽整个的⽂本信息就相当于是query,value的含义则更抽象,可以⽐作是你看到这段⽂本信息后,脑⼦⾥浮现的答案信息,这⾥我们⼜假设⼤家最开始都不是很聪明,第⼀次看到这段⽂本后脑⼦⾥基本上浮现的信息就只有提示这些信息,因此key与value基本是相同的,但是随着我们对这个问题的深⼊理解,通过我们的思考脑⼦⾥想起来的东⻄原来越多,并且能够开始对我们query也就是这段⽂本,提取关键信息进⾏表示. 这就是注意⼒作⽤的过程, 通过这个过程,我们最终脑⼦⾥的value发⽣了变化,根据提示key⽣成了query的关键词表示⽅法,也就是另外⼀种特征表示⽅法.刚刚我们说到key和value⼀般情况下默认是相同,与query是不同的,这种是我们⼀般的注意⼒输⼊形式,但有⼀种特殊情况,就是我们query与key和value相同,这种情况我们称为⾃注意⼒机制,就如同我们的刚刚的例⼦,使⽤⼀般注意⼒机制,是使⽤不同于给定⽂本的关键词表示它. ⽽⾃注意⼒机制,需要⽤给定⽂本⾃身来表达⾃⼰,也就是说你需要从给定⽂本中抽取关键词来表述它, 相当于对⽂本⾃身的⼀次特征提取。
什么是注意⼒机制:
- 注意⼒机制是注意⼒计算规则能够应⽤的深度学习⽹络的载体, 除了注意⼒计算规则外,还包括⼀些必要的全连接层以及相关张量处理, 使其与应⽤⽹络融为一体. 使⽤⾃注意⼒计算规则的注意⼒机制称为⾃注意⼒机制
代码实现:
def attention(query, key, value, mask=None, dropout=None):
#注意⼒机制的实现, 输⼊分别是query, key, value, mask: 掩码张量,
#dropout是nn.Dropout层的实例化对象, 默认为None"""
# 在函数中, ⾸先取query的最后⼀维的⼤⼩,
#⼀般情况下就等同于我们的词嵌⼊维度, 命名为d_k
d_k = query.size(-1)
# 按照注意⼒公式, 将query与key的转置相乘, 这⾥⾯key是将最后两个维度进⾏转置
# 再除以缩放系数根号下d_k, 这种计算⽅法也称为缩放点积注意⼒计算.
# 得到注意⼒得分张量scores
scores = torch.matmul(query, key.transpose(-2, -1)) / math.sqrt(d_k)
# 接着判断是否使⽤掩码张量
if mask is not None:
# 使⽤tensor的masked_fill⽅法, 将掩码张量和scores张量每个位置⼀⼀⽐较,
# 如果掩码张量处为0,则对应的scores张量⽤-1e9这个值来替换, 如下演示
scores = scores.masked_fill(mask == 0, -1e9)
# 对scores的最后⼀维进⾏softmax操作, 使⽤F.softmax⽅法,
# 第⼀个参数是softmax对象, 第⼆个是⽬标维度.
# 这样获得最终的注意⼒张量
p_attn = F.softmax(scores, dim = -1)
# 之后判断是否使⽤dropout进⾏随机置0
if dropout is not None:
# 将p_attn传⼊dropout对象中进⾏'丢弃'处理
p_attn = dropout(p_attn)
# 最后, 根据公式将p_attn与value张量相乘获得最终的query注意⼒表示,
# 同时返回注意⼒张量
return torch.matmul(p_attn, value), p_attn
实现:
query = key = value = pe_result # torch.Size([2, 4, 512])
attn, p_attn = attention(query, key, value)
print('attn==', attn.shape) #attn== torch.Size([2, 4, 512])
print('p_attn==',p_attn.shape) # p_attn== torch.Size([2, 4, 4])
效果(非常简单的演示,没有实际意义):
Part3:多头注意力机制
什么是多头注意⼒机制:
- 从多头注意⼒的结构图中,貌似这个所谓的多个头就是指多组线性变换层,其实并不是,只使⽤了⼀组线性变化层,即三个变换张量对Q,K,V分别进⾏线性变换,这些变换不会改变原有张量的尺⼨,因此每个变换矩阵都是⽅阵,得到输出结果后,多头的作⽤才开始显现,每个头开始从词义层⾯分割输出的张量,也就是每个头都想获得⼀组Q,K,V进⾏注意⼒机制的计算,但是句⼦中的每个词的表示只获得⼀部分,也就是只分割了最后⼀维的词嵌⼊向量. 这就是所谓的多头,将每个头的获得的输⼊送到注意⼒机制中, 就形成多头注意⼒机制.
结构图:
多头注意⼒机制的作⽤:
- 这种结构设计能让每个注意⼒机制去优化每个词汇的不同特征部分,从⽽均衡同⼀种注意⼒机制可能产⽣的偏差,让词义拥有来⾃更多元的表达,实验表明可以从⽽提升模型效果
代码实现: 深拷贝函数:
# ⾸先需要定义克隆函数, 因为在多头注意⼒机制的实现中, ⽤到多个结构相同的线性层.
# 我们将使⽤clone函数将他们⼀同初始化在⼀个⽹络层列表对象中,
# 之后的结构中也会⽤到该函数.
def clones(module, N):
return nn.ModuleList([copy.deepcopy(module) for _ in range(N)])
定义多头注意力类:
class MultiHeadedAttention(nn.Module):
def __init__(self, head, embedding_dim, dropout=0.1):
super(MultiHeadedAttention, self).__init__()
assert embedding_dim % head == 0
self.d_k = embedding_dim // head
self.head = head
self.linears = clones(nn.Linear(embedding_dim, embedding_dim), 4)
self.attn = None
self.dropout = nn.Dropout(p=dropout)
def forward(self, query, key, value, mask=None):
if mask is not None:
mask = mask.unsqueeze(0)
batch_size = query.size(0)
# view中的四个参数的意义
# batch_size: 批次的样本数量
# -1这个位置应该是: 每个句子的长度
# self.head*self.d_k应该是embedding的维度,
# 这里把词嵌入的维度分到了每个头中,
# 即每个头中分到了词的部分维度的特征
# 之后就进⼊多头处理环节
query, key, value = [model(x).view(batch_size, -1, self.head, self.d_k).transpose(1, 2) for model, x in
zip(self.linears, (query, key, value))]
#这里的所有参数都是4维度的 进过dropout的也是4维度的
x, self.attn = attention(query, key, value, mask=mask, dropout=self.dropout)
# contiguous解释:https://zhuanlan.zhihu.com/p/64551412
# 这里相当于图中concat过程
x = x.transpose(1, 2).contiguous().view(batch_size, -1, self.head * self.d_k)
return self.linears[-1](x)
代码解释:
-
在类的初始化时, 会传⼊三个参数,head代表头数,embedding_dim代表词嵌⼊的维度,dropout代表进⾏dropout操作时置0⽐率,默认是0.1。
-
在函数中,⾸先使⽤了⼀个测试中常⽤的assert语句,判断h是否能被d_model整除,这是因为我们之后要给每个头分配等量的词特征.也就是embedding_dim/head个。
-
得到每个头获得的分割词向量维度d_k。
-
传⼊头数h。
-
然后获得线性层对象,通过nn的Linear实例化,它的内部变换矩阵是embedding_dim*embedding_dim,然后使⽤clones函数克隆四个。(为什么是四个呢,因为在多头注意⼒中,Q,K,V各需要⼀个,最后拼接的矩阵还需要⼀个,因此⼀共是四个)
-
self.attn为None,它代表最后得到的注意⼒张量,现在还没有结果所以为None。
-
最后就是⼀个self.dropout对象,它通过nn中的Dropout实例化⽽来,置0⽐率为传进来的参数dropout。
-
利⽤zip将输⼊Q、K、V与三个线性层组到⼀起,然后使⽤for循环,将输⼊QKV分别传到线性层中,做完线性变换后,开始为每个头分割输⼊,这⾥使⽤view⽅法对线性变换的结果进⾏维度重塑,多加了⼀个维度h,代表头数,这样就意味着每个头可以获得⼀部分词特征组成的句⼦,其中的-1代表⾃适应维度,计算机会根据这种变换⾃动计算这⾥的值.然后对第⼆维和第三维进⾏转置操作,为了让代表句⼦⻓度维度和词向量维度能够相邻,这样注意⼒机制才能找到词义与句⼦位置的关系,从attention函数中可以看到,利⽤的是原始输⼊的倒数第⼀和第⼆维.这样我们就得到了每个头的输⼊。
-
得到每个头的输⼊后,接下来就是将他们传⼊到attention中,这⾥直接调⽤之前实现的attention函数.同时也将mask和dropout传⼊其中。
-
通过多头注意⼒计算后,我们就得到了每个头计算结果组成的4维张量,我们需要将其转换为输⼊的形状以⽅便后续的计算,因此这⾥开始进⾏第⼀步处理环节的逆操作,先对第⼆和第三维进⾏转置,然后使⽤contiguous⽅法,这个⽅法的作⽤就是能够让转置后的张量应⽤view⽅法,否则将⽆法直接使⽤, 所以,下一步就是使⽤view重塑形状,变成和输⼊形状相同。
-
最后使⽤线性层列表中的最后⼀个线性层对输⼊进⾏线性变换得到最终的多头注意⼒结构的输出。
结果:
Part4:前馈全连接
什么是前馈全连接层:
- 在Transformer中前馈全连接层就是具有两层线性层的全连接⽹络
前馈全连接层的作⽤:
- 考虑注意⼒机制可能对复杂过程的拟合程度不够, 通过增加两层⽹络来增强模型的能⼒
代码实现:
class PositionwiseFeedForward(nn.Module):
def __init__(self, d_model, d_ff, dropout=0.1):
#初始化函数有三个输⼊参数分别是d_model, d_ff,和dropout=0.1,
#第⼀个是线性层的输⼊维度也是第⼆个线性层的输出维度,
#因为我们希望输⼊通过前馈全连接层后输⼊和输出的维度不变. 第⼆个参数d_ff就是第
#⼆个线性层的输⼊维度和第⼀个线性层的输出维度.最后⼀个是dropout置0⽐率.
super(PositionwiseFeedForward, self).__init__()
# ⾸先按照预期使⽤nn实例化了两个线性层对象,self.w1和self.w2
# 它们的参数分别是d_model, d_ff和d_ff, d_model
self.w1 = nn.Linear(d_model, d_ff)
self.w2 = nn.Linear(d_ff, d_model)
# 然后使⽤nn的Dropout实例化了对象self.dropout
self.dropout = nn.Dropout(dropout)
def forward(self, x):
#输⼊参数为x,代表来⾃上⼀层的输出#
# ⾸先经过第⼀个线性层,然后使⽤Funtional中relu函数进⾏激活,
# 之后再使⽤dropout进⾏随机置0,最后通过第⼆个线性层w2,返回最终结果.
return self.w2(self.dropout(F.relu(self.w1(x))))
Part5:规范化层
规范化层的作⽤:
- 它是所有深层⽹络模型都需要的标准⽹络层,因为随着⽹络层数的增加,通过多层的计算后参数可能开始出现过⼤或过⼩的情况,这样可能会导致学习过程出现异常,模型可能收敛⾮常的慢. 因此都会在⼀定层数后接规范化层进⾏数值的规范化,使其特征数值在合理范围内.
代码实现:
class LayerNorm(nn.Module):
def __init__(self, features, eps=1e-6):
#"""初始化函数有两个参数, ⼀个是features, 表示词嵌⼊的维度,
#另⼀个是eps它是⼀个⾜够⼩的数, 在规范化公式的分⺟中出现,
#防⽌分⺟为0.默认是1e-6."""
super(LayerNorm, self).__init__()
# 根据features的形状初始化两个参数张量a2,和b2,第⼀个初始化为1张量,
# 也就是⾥⾯的元素都是1,第⼆个初始化为0张量,也就是⾥⾯的元素都是0,
#这两个张量就是规范化层的参数,
# 因为直接对上⼀层得到的结果做规范化公式计算,将改变结果的正常表征,
#因此就需要有参数作为调节因⼦,
# 使其即能满⾜规范化要求,⼜能不改变针对⽬标的表征.
#最后使⽤nn.parameter封装,代表他们是模型的参数。
self.a2 = nn.Parameter(torch.ones(features))
self.b2 = nn.Parameter(torch.zeros(features))
# 把eps传到类中
self.eps = eps
def forward(self, x):
#"""输⼊参数x代表来⾃上⼀层的输出"""
# 在函数中,⾸先对输⼊变量x求其最后⼀个维度的均值,并保持输出维度与输⼊维度⼀致.
# 接着再求最后⼀个维度的标准差,然后就是根据规范化公式,⽤x减去均值除以标准差获得规范化的结果,
# 最后对结果乘以我们的缩放参数,即a2,*号代表同型点乘,
#即对应位置进⾏乘法操作,加上位移参数b2.返回即可.
mean = x.mean(-1, keepdim=True)
std = x.std(-1, keepdim=True)
return self.a2 * (x - mean) / (std + self.eps) + self.b2
part6:子层链接结构
什么是⼦层连接结构:
- 输⼊到每个⼦层以及规范化层的过程中,还使⽤了残差链接(跳跃连接),因此把这⼀部分结构整体叫做⼦层连接(代表⼦层及其链接结构),在每个编码器层中,都有两个⼦层,这两个⼦层加上周围的链接结构就形成了两个⼦层连接结构。
class SublayerConnection(nn.Module):
def __init__(self, size, dropout=0.1):
#它输⼊参数有两个, size以及dropout, size⼀般是都是词嵌⼊维度的⼤⼩,
#dropout本身是对模型结构中的节点数进⾏随机抑制的⽐率,
#⼜因为节点被抑制等效就是该节点的输出都是0,
#因此也可以把dropout看作是对输出矩阵的随机置0的⽐率.
super(SublayerConnection, self).__init__()
# 实例化了规范化对象self.norm
self.norm = LayerNorm(size)
# ⼜使⽤nn中预定义的droupout实例化⼀个self.dropout对象.
self.dropout = nn.Dropout(p=dropout)
def forward(self, x, sublayer):
#前向逻辑函数中, 接收上⼀个层或者⼦层的输⼊作为第⼀个参数,将该⼦层连接中的⼦层函数作为第⼆个参数
# 我们⾸先对输出进⾏规范化,然后将结果传给⼦层处理,之后再对⼦层进⾏dropout操作,
# 随机停⽌⼀些⽹络中神经元的作⽤,来防⽌过拟合. 最后还有⼀个add操作,
# 因为存在跳跃连接,所以是将输⼊x与dropout后的⼦层输出结果相加作为最终的⼦层连接输出.
return x + self.dropout(sublayer(self.norm(x)))
调用:
size = 512
dropout = 0.2
head = 8
d_model = 512
# 令x为位置编码器的输出
x = pe_result
mask = Variable(torch.zeros(8, 4, 4))
# 假设⼦层中装的是多头注意⼒层, 实例化这个类
self_attn = MultiHeadedAttention(head, d_model)
# 使⽤lambda获得⼀个函数类型的⼦层
sublayer = lambda x: self_attn(x, x, x, mask)
sc = SublayerConnection(size, dropout)
sc_result = sc(x, sublayer)
print(sc_result)
print(sc_result.shape)
part7:编码器层
编码器层的作⽤:
- 作为编码器的组成单元, 每个编码器层完成⼀次对输⼊的特征提取过程, 即编码过程。
代码实现:
class EncoderLayer(nn.Module):
def __init__(self, size, self_attn, feed_forward, dropout):
#它的初始化函数参数有四个,分别是size,其实就是词嵌⼊维度的⼤⼩,它也将作为我们编码器层的⼤⼩,
#第⼆个self_attn,之后将传⼊多头⾃注意⼒⼦层实例化对象, 并且是⾃注意⼒机制,
#第三个是feed_froward, 之后将传⼊前馈全连接层实例化对象, 最后⼀个是置0⽐率dropout。
super(EncoderLayer, self).__init__()
# ⾸先将self_attn和feed_forward传⼊其中.
self.self_attn = self_attn
self.feed_forward = feed_forward
# 编码器层中有两个⼦层连接结构, 所以使⽤clones函数进⾏克隆
self.sublayer = clones(SublayerConnection(size, dropout), 2)
# 把size传⼊其中
self.size = size
def forward(self, x, mask):
#forward函数中有两个输⼊参数,x和mask,分别代表上⼀层的输出,和掩码张量mask.
#⾥⾯就是按照结构图左侧的流程. ⾸先通过第⼀个⼦层连接结构,其中包含多头⾃注意⼒⼦层,
# 然后通过第⼆个⼦层连接结构,其中包含前馈全连接⼦层. 最后返回结果.
x = self.sublayer[0](x, lambda x: self.self_attn(x, x, x, mask))
return self.sublayer[1](x, self.feed_forward)
part8:编码器
编码器的作⽤:
- 编码器⽤于对输⼊进⾏指定的特征提取过程, 也称为编码, 由N个编码器层堆叠⽽成
代码实现:
class Encoder(nn.Module):
def __init__(self, layer, N):
#初始化函数的两个参数分别代表编码器层和编码器层的个数
super(Encoder, self).__init__()
# ⾸先使⽤clones函数克隆N个编码器层放在self.layers中
self.layers = clones(layer, N)
# 再初始化⼀个规范化层, 它将⽤在编码器的最后⾯.
self.norm = LayerNorm(layer.size)
def forward(self, x, mask):
#forward函数的输⼊和编码器层相同, x代表上⼀层的输出, mask代表掩码张量
# ⾸先就是对克隆的编码器层进⾏循环,每次都会得到⼀个新的x,
# 这个循环的过程,就相当于输出的x经过了N个编码器层的处理.
# 最后再通过规范化层的对象self.norm进⾏处理,最后返回结果.
for layer in self.layers:
x = layer(x, mask)
return self.norm(x)
以上是实现了一个简单的编码器结构