2018年10月,Google发出一篇论文《BERT:Pre-training of Deep Bidirectional Transformers forLanguage Understanding》,ERT模型横空出世,并横扫NLP领域11项任务的最佳成绩!
论文地址: https:/arxiv.org/pdf/1810.04805.pdf
而在BERT中发挥重要作用的结构就是Transformer,之后又相继出现XLNET,rOBERT等模型击败了BERT,但是他们的核心没有变,仍然是:Transformer。
相比之前占领市场的LSTM和GRU模型,Transformer有两个显著的优势:
(1)Transformer能够利用分布式GPU进行并行训练,提升模型训练效率;
(2)在分析预测更长的文本时,捕捉间隔较长的语义关联效果更好。
在著名的SOTA机器翻译榜单上,几乎所有排名靠前的模型都使用Transformer。
WMT2014 English-German Benchmark (Machine Translation) | Papers With Code

其基本上可以看作是工业界的风钧标,市场空间自然不必多说!
(1)学习目标:
(2)Transformer模型的作用:
基于seq2seq架构的transformer模型可以完成NLP领域研究的典型任务,如机器翻译,文本生成等,同时又可以构建预训练语言模型,用于不同任务的迁移学习
(3)声明:
在接下来的架构分析中,我们将假设使用Transformer模型架构处理从一种语言文本到另一种语言文本的翻译工作,因此很多命名方式遵循NLP中的规则。比如: Embeddding层将称作文本嵌入层,Embedding层产生的张量称为词嵌入张量,它的最后一维将称作词向量等。
(4)Transformer总体架构图:

(5)Transformer总体架构可分为四个部分:
(6)输入部分包含:

(7) 输出部分包含:

(8) 编码器部分:

(9)解码器部分:

(10)小节总结:
本节学习了Transformer模型的作用:
Transformer总体架构可分为四个部分:
输入部分包含:
输出部分包含:
编码器部分:
解码器部分:
学习目标:
输入部分包含:
文本嵌入层的作用:
文本嵌入层的代码分析:
- import torch
- import torch.nn as nn
- import torch.nn.functional as F
- from torch.autograd import Variable
- import math
- import matplotlib.pyplot as plt
- import numpy as np
- import copy
文本嵌入层:
- # 构建Embedding类来实现文本嵌入层
- class Embeddings(nn.Module):
- def __init__(self,d_model,vocab):
- # dmodel: 词嵌入的维度
- # vocab: 词表的大小
- super(Embeddings,self).__init__()
- # 定义Embedding层
- self.lut = nn.Embedding(vocab,d_model)
- # 将参数传入类中
- self.d_model = d_model
-
- def forward(self,x):
- # x: 代表输入进模型的文本通过词汇映射后的数字张量
- return self.lut(x)* math.sqrt(self.d_model)
-
- # 调用类
- d_model=512
- vocab = 1000
- x = Variable(torch.LongTensor([[100,2,421,508],[491,998,1,221]]))
- emb = Embeddings(d_model, vocab)
- embr = emb(x)
- print("embr:", embr)
- print(embr.shape)
位置编码器的作用:
位置编码器代码分析:
- class PositionalEncoding(nn.Module):
- """ 定义位置编码器类, 我们同样把它看做一个层, 因此会继承nn.Module """
- def __init__(self, d_model, dropout, max_len=5000):
- """
- 位置编码器类的初始化函数, 共有三个参数, 分别是:
- :param d_model: 词嵌入维度
- :param dropout: 置0比率
- :param max_len: 每个句子的最大长度
- """
- super(PositionalEncoding, self).__init__()
-
- # 实例化nn中预定义的Dropout层, 并将dropout传入其中, 获得对象self.dropout
- self.dropout = nn.Dropout(p=dropout)
-
- # 初始化一个位置编码矩阵, 它是一个0阵, 矩阵的大小是max_len x d_model.
- pe = torch.zeros(max_len, d_model)
-
- # 初始化一个绝对位置矩阵, 在我们这里, 词汇的绝对位置就是用它的索引去表示.
- # 所以我们首先使用arange方法获得一个连续自然数向量, 然后再使用unsqueeze方法拓展向量维度使其成为矩阵,
- # 又因为参数传的是1, 代表矩阵拓展的位置, 会使向量变成一个max_len x 1 的矩阵,
- position = torch.arange(0, max_len).unsqueeze(1)
-
- # 绝对位置矩阵初始化之后, 接下来就是考虑如何将这些位置信息加入到位置编码矩阵中,
- # 最简单思路就是先将max_len x 1的绝对位置矩阵, 变换成max_len x d_model形状, 然后覆盖原来的初始位置编码矩阵即可,
- # 要做这种矩阵变换, 就需要一个1xd_model形状的变换矩阵div_term, 我们对这个变换矩阵的要求除了形状外,
- # 还希望它能够将自然数的绝对位置编码缩放成足够小的数字, 有助于在之后的梯度下降过程中更快的收敛. 这样我们就可以开始初始化这个变换矩阵了.
- # 首先使用arange获得一个自然数矩阵, 但是细心的同学们会发现, 我们这里并没有按照预计的一样初始化一个1xd_model的矩阵,
- # 而是有了一个跳跃, 只初始化了一半即1xd_model/2 的矩阵. 为什么是一半呢, 其实这里并不是真正意义上的初始化了一半的矩阵,
- # 我们可以把它看作是初始化了两次, 而每次初始化的变换矩阵会做不同的处理, 第一次初始化的变换矩阵分布在正弦波上, 第二次初始化的变换矩阵分布在余弦波上,
- # 并把这两个矩阵分别填充在位置编码矩阵的偶数和奇数位置上, 组成最终的位置编码矩阵.
- div_term = torch.exp(torch.arange(0, d_model, 2) * -(math.log(10000.0) / d_model))
- pe[:, 0::2] = torch.sin(position * div_term)
- pe[:, 1::2] = torch.cos(position * div_term)
-
- # 这样我们就得到了位置编码矩阵pe, pe现在还只是一个二维矩阵, 要想和embedding的输出(一个三维张量)相加,
- # 就必须拓展一个维度, 所以这里使用unsqueeze拓展维度.
- pe = pe.unsqueeze(0)
-
- # 最后把pe位置编码矩阵注册成模型的buffer, 什么是buffer呢,
- # 我们把它认为是对模型效果有帮助的, 但是却不是模型结构中超参数或者参数, 不需要随着优化步骤进行更新的增益对象.
- # 注册之后我们就可以在模型保存后重加载时和模型结构与参数一同被加载.
- self.register_buffer('pe', pe)
-
- def forward(self, x):
- """forward函数的参数是x, 表示文本序列的词嵌入表示"""
- # 在相加之前我们对pe做一些适配工作, 将这个三维张量的第二维也就是句子最大长度的那一维将切片到与输入的x的第二维相同即x.size(1),
- # 因为我们默认max_len为5000一般来讲实在太大了, 很难有一条句子包含5000个词汇, 所以要进行与输入张量的适配.
- # 最后使用Variable进行封装, 使其与x的样式相同, 但是它是不需要进行梯度求解的, 因此把requires_grad设置成false.
- x = x + Variable(self.pe[:, :x.size(1)],
- requires_grad=False)
- # 最后使用self.dropout对象进行'丢弃'操作, 并返回结果.
- return self.dropout(x)
-
- # 调用类
- # 词嵌入维度是512维
- d_model = 512
- # 置0比率为0.1
- dropout = 0.1
- # 句子最大长度
- max_len=60
-
- # 输入x是Embedding层的输出的张量, 形状是2 x 4 x 512
- x = embr
- pe = PositionalEncoding(d_model, dropout, max_len)
- pe_result = pe(x)
- print("pe_result:", pe_result)
- # 形状是2 x 4 x 512
- print("pe_result shape:", pe_result.shape)
绘制词汇向量中特征的分布曲线:
- import matplotlib.pyplot as plt
-
- # 创建一张15 x 5大小的画布
- plt.figure(figsize=(15, 5))
-
- # 实例化PositionalEncoding类得到pe对象, 输入参数是20和0
- pe = PositionalEncoding(20, 0)
-
- # 然后向pe传入被Variable封装的tensor, 这样pe会直接执行forward函数,
- # 且这个tensor里的数值都是0, 被处理后相当于位置编码张量
- y = pe(Variable(torch.zeros(1, 100, 20)))
-
- # 然后定义画布的横纵坐标, 横坐标到100的长度, 纵坐标是某一个词汇中的某维特征在不同长度下对应的值
- # 因为总共有20维之多, 我们这里只查看4,5,6,7维的值.
- plt.plot(np.arange(100), y[0, :, 4:8].data.numpy())
-
- # 在画布上填写维度提示信息
- plt.legend(["dim %d"%p for p in [4,5,6,7]])
输出效果:

输出效果分析:
小节总结
学习目标

- def subsequent_mask(size):
- """
- 构建掩码张量的函数
- :param size: 代表掩码张量最后两个维度, 形成一个方阵
- :return:
- """
- # 在函数中, 首先定义掩码张量的形状
- attn_shape = (1, size, size)
-
- # 使用 np.ones() 先构建一个全1的张量, 然后利用 np.triu() 形成上三角阵
- # 最后为了节约空间, 再使其中的数据类型变为无符号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)
-
- # 生成的掩码张量的最后两维的大小
- size = 5
-
- sm = subsequent_mask(size)
- print("sm:", sm)
可视化展示:

效果分析:
总结:
![]()
- 假如我们有一个问题: 给出一段文本,使用一些关键词对它进行描述!
- 为了方便统一正确答案,这道题可能预先已经给大家写出了一些关键词作为提示。其中这些给出的提示就可以看作是key, 而整个的文本信息就相当于是query,value的含义则更抽象,可以比作是你看到这段文本信息后,脑子里浮现的答案信息。这里我们又假设大家最开始都不是很聪明,第一次看到这段文本后脑子里基本上浮现的信息就只有提示这些信息,因此key与value基本是相同的,但是随着我们对这个问题的深入理解,通过我们的思考脑子里想起来的东西原来越多,并且能够开始对我们query也就是这段文本,提取关键信息进行表示。这就是注意力作用的过程, 通过这个过程,
-
- 我们最终脑子里的value发生了变化,
- 根据提示key生成了query的关键词表示方法,也就是另外一种特征表示方法。
-
- 刚刚我们说到key和value一般情况下默认是相同,与query是不同的,这种是我们一般的注意力输入形式,
- 但有一种特殊情况,就是我们query与key和value相同,这种情况我们称为自注意力机制,就如同我们的刚刚的例子,使用一般注意力机制,是使用不同于给定文本的关键词表示它。 而自注意力机制, 需要用给定文本自身来表达自己,也就是说你需要从给定文本中抽取关键词来表述它, 相当于对文本自身的一次特征提取。

- def attention(query, key, value, mask=None, dropout=None):
- """
- 注意力机制的实现
- :param query: 输入张量
- :param key: 输入张量
- :param value: 输入张量
- :param mask: 掩码张量
- :param dropout: 传入的nn.Dropout层的实例化对象, 默认为None
- :return:
- """
- # 在函数中, 首先将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
-
-
- # 带有mask的输入参数
- query = key = value = pe_result
-
- # 令mask为一个2*4*4的零张量
- mask = Variable(torch.zeros(2, 4, 4))
-
- attn, p_attn = attention(query, key, value, mask=mask)
- print("attn:", attn)
- print("p_attn:", p_attn)
总结:

- # 用于深度拷贝的copy工具包
- import copy
-
- # 首先需要定义克隆函数, 因为在多头注意力机制的实现中, 用到多个结构相同的线性层.
- # 我们将使用clone函数将他们一同初始化在一个网络层列表对象中. 之后的结构中也会用到该函数.
- def clones(module, N):
- """
- 用于生成相同网络层的克隆函数
- :param module: module表示要克隆的目标网络层
- :param N: N代表需要克隆的数量
- :return:
- """
- # 在函数中, 我们通过for循环对module进行N次深度拷贝, 使其每个module成为独立的层,
- # 然后将其放在nn.ModuleList类型的列表中存放.
- return nn.ModuleList([copy.deepcopy(module) for _ in range(N)])
-
-
- class MultiHeadedAttention(nn.Module):
- """ 实现多头注意力机制 """
- def __init__(self, head, embedding_dim, dropout=0.1):
- """
- 在类的初始化时, 会传入三个参数:
- :param head: head代表头数
- :param embedding_dim: embedding_dim代表词嵌入的维度
- :param dropout: dropout代表进行dropout操作时置零的比率, 默认是0.1
- """
- super(MultiHeadedAttention, self).__init__()
-
- # 要确认的一个事实: 多头的数量head需要整除词嵌入的维度embedding_dim
- # 在函数中, 首先使用了一个测试中常用的assert语句, 判断head是否能被d_model整除,
- # 这是因为我们之后要给每个头分配等量的词特征. 也就是embedding_dim/head个.
- assert embedding_dim % head == 0
-
- # 得到每个头获得的词向量的维度d_k
- self.d_k = embedding_dim // head
-
- # 传入头数h
- self.head = head
-
- # 然后获得线性层对象,通过nn的Linear实例化,它的内部变换矩阵是embedding_dim * embedding_dim,然后使用clones函数克隆四个,
- # 为什么是四个呢, 这是因为在多头注意力中,Q,K,V各需要一个, 最后拼接的矩阵还需要一个, 因此一共是四个.
- self.linears = clones(nn.Linear(embedding_dim, embedding_dim), 4)
-
- # self.attn为None, 它代表最后得到的注意力张量, 现在还没有结果所以为None.
- self.attn = None
-
- # 最后就是一个self.dropout对象, 它通过nn中的Dropout实例化而来, 置零比率为传进来的参数dropout.
- self.dropout = nn.Dropout(p=dropout)
-
- def forward(self, query, key, value, mask=None):
- """
- 前向逻辑函数, 它的输入参数有四:
- :param query: Q
- :param key: K
- :param value: V
- :param mask: mask掩码张量,默认是None
- :return:
- """
-
- # 如果存在掩码张量mask
- if mask is not None:
- # 使用unsqueeze拓展维度
- mask = mask.unsqueeze(0)
-
- # 接着,我们获得一个batch_size的变量,他是query尺寸的第1个数字,代表有多少条样本.
- batch_size = query.size(0)
-
- # 之后就进入多头处理环节
- # 首先利用zip将输入QKV与三个线性层组到一起, 然后使用for循环, 将输入QKV分别传到线性层中,
- # 做完线性变换后, 开始为每个头分割输入, 这里使用view方法对线性变换的结果进行维度重塑, 多加了一个维度h, 代表头数,
- # 这样就意味着每个头可以获得一部分词特征组成的句子, 其中的-1代表自适应维度,
- # 计算机会根据这种变换自动计算这里的值. 然后对第二维和第三维进行转置操作,
- # 为了让代表句子长度维度和词向量维度能够相邻, 这样注意力机制才能找到词义与句子位置的关系,
- # 从attention函数中可以看到, 利用的是原始输入的倒数第一和第二维. 这样我们就得到了每个头的输入.
- 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))]
-
- # 得到每个头的输入后,接下来就是将他们传入到attention中,
- # 这里直接调用我们之前实现的attention函数.同时也将mask和dropout传入其中.
- x, self.attn = attention(query, key, value, mask=mask, dropout=self.dropout)
-
- # 通过多头注意力计算后, 我们就得到了每个头计算结果组成的4维张量, 我们需要将其转换为输入的形状以方便后续的计算,
- # 因此这里开始进行第一步处理环节的逆操作, 先对第二和第三维进行转置, 然后使用contiguous方法,
- # 这个方法的作用就是能够让转置后的张量应用view方法, 否则将无法直接使用,
- # 所以, 下一步就是使用view重塑形状, 变成和输入形状相同, self.head * self.d_k = embedding_dim.
- x = x.transpose(1, 2).contiguous().view(batch_size, -1, self.head * self.d_k)
-
- # 最后使用线性层列表中的最后一个线性层对输入进行线性变换得到最终的多头注意力结构的输出.
- return self.linears[-1](x)
-
-
- # 头数head
- head = 8
- # 词嵌入维度embedding_dim
- embedding_dim = 512
- # 置零比率dropout
- dropout = 0.2
-
- # 假设输入的Q,K,V仍然相等
- query = value = key = pe_result
- # 输入的掩码张量mask
- mask = Variable(torch.zeros(8, 4, 4))
- mha = MultiHeadedAttention(head, embedding_dim, dropout)
- mha_result = mha(query, key, value, mask)
- print(mha_result)
- print(mha_result.shape)
多头注意力机制总结: