七、Transformer
看本文前一定一定要先看注意力机制篇章:【NLP】第五章:注意力机制Attention-CSDN博客
和位置编码偏置:【NLP】第六章:位置编码Positional Encoding-CSDN博客
本文对这俩部分的讲解是掠过的!因为注意力机制是Transformer的核心,position encoding又太难,所以我分别单独开了一个章节来讲透attention和PE。尽量不要有太多的知识断点,你理解起来就比较容易。
(一)Transformer简介
2017年,谷歌机器翻译团队发表的《Attention is all you need》论文,以seq2seq架构(编码器-解码器)为基础、完全基于自注意力机制,提出了全新的Transformer模型架构。该模型抛弃了以往机器翻译基本都会应用的RNN或CNN等传统架构,从架构层面解决了,RNN和CNN无法并行处理,以及无法高效捕捉长距离依赖的问题。
话短事大,Transformer诞生之后,就以迅雷不及掩耳之势催生了:2018年3月份华盛顿大学提出ELM、2018年6月份OpenAI提出GPT、2018年10月份Google提出BERT、2019年6月份CMU+google brain提出XLNet等等一系列的里程碑式模型。
2018年10月,谷歌发布的《BERT:Pre-training of Deep Bidirectional Transformers for Language Understanding》论文,BERT模型横空出世,直接横扫了NLP领域11个项目任务的最佳成绩!风头无二!但是在BERT中发挥重要作用的结构就是Transformer,之后又出现的XLNET,roBERT等模型虽然击败了BERT,但是它们的核心没有变,仍然是Transformer。直接点燃全球狂热的AIGC应用的GPT底层也是Transformer。
虽然Transformer起源于NLP领域,但此后迅速在图像、视频、声音等领域都得到了广泛应用的基础架构,成为继承MLP(就是我们之前经常说的FNN或DNN)、CNN、RNN后的公认的第四大基础模型结构。
Transformer的两个显著优势:
一是,Tranformer能够利用分布式GPU进行并行训练,提升模型训练效率。Transformer虽然是一个解决时序问题的模型,但它的架构不是像RNN一族那样在时间维度上循环的架构,可以并行训练。
二是,在分析预测更长的文本时,捕捉间隔较长的语义关联效果更好。
NLP发展到今天,当前呈现的是两条道路:一是基于大语言模型去进行一系列的应用和开发的道路。另外一条是,很多公司试图基于自己的算力、基于tranformer来开发和训练自己的大语言模型,也就是相当于回归到深度学习本身来了。所以transformer就变得更加关键了。transformer不仅可以完成NLP领域研究的典型任务,比如机器翻译、文本生成等,同时又可以构建预训练语言模型,用于不同任务的迁移学习。目前像hugging-face的调用、transformer的应用、transformer和其他架构的结合等案例层出不穷,比如基于transformer的机器翻译、生成式案例、情感分类、transformer用于词性标注、transformer用于股价预测等等。
(二)Transformer整体架构
1、Transformer架构各个组成部分
这是论文《Attention is all you need》中的Transformer架构图,我们一般分四大部分:
(1)输入部分:包括嵌入层和位置编码器。
(2)编码器部分:
- 由N个编码器层堆叠而成。所以N就是一个超参数。
- 每个编码器都由两个子层连接结构组成。
- 第一个子层连接结构包括一个多头自注意力子层和规范化层以及一个残差连接。
- 第二个子层连接结构包括一个前馈全连接子层和规范化层以及一个残差连接。
(3)解码器部分:
- 由N个解码器层堆叠而成。所以N也是一个超参数。
- 每个解码器由三个子层连接结构组成。
- 第一个子层连接结构包括一个带掩码的多头自注意力子层和规范化层以及一个残差连接。
- 第二个子层连接结构包括一个多头注意力子层和规范化层以及一个残差连接。
- 第三个子层连接结构包括一个前馈全连接子层和规范化层以及一个残差连接。
(4)输出部分:一个全连接层+一个softmax层。
大多数架构的输出层都是这样设计的,全连接层用来整理输出,得到output size。softmax使输出结果看起来是一个类概率的值,这样就可以把最大概率的那个值提取出来了。
2、Transformer的具体架构是随任务而定的
上图是论文《Attention is all you need》中的Transformer架构图。所以上面的架构是针对机器翻译任务的,就是从一种语言文本到另一种语言文本的翻译任务。
但是在NLP领域中,不同的任务是需要不同的Transformer架构的,就是不同任务对应不同的架构搭建。所以我们需要根据不同的NLP任务需求,来组合搭建Transformer中的各个部件,以适应不同的应用场景:
(1)只是使用编码器的任务:
编码器部分的任务是从输入数据中提取特征。编码器通常用于不需要生成新文本序列的任务,比如:
文本分类:如情感分析、垃圾邮件检测、文本分类(bert)等,输入一个文本序列,编码器提取特征后进行分类。
命名实体识别(Named Entity Recognition,NER):在给定文本中识别实体(如人名、地名等),这也是分类问题的一种,可以用编码器提取文本的特征。
句子相似度:判断两个句子是否相关或相似度如何,可以通过编码器提取句子特征后计算相似度。
(2)只使用解码器的任务:
解码器部分专注于生成新文本,通常使用自回归方式,基于之前的输出生成下一个词。适用于:
文本生成:如GPT系列,只使用解码器生成文本,例如故事续写、文章生成等。
文本续写或预测:训练解码器来预测下一个单词或字符。
(3)编码器和解码器都使用的任务:
当任务涉及到理解输入序列并生成新的输出序列时,编码器和解码器会联合使用,比如:
机器翻译:编码器负责理解源语言,解码器负责生成目标语言。
文本摘要:编码器理解原始文本,解码器生成摘要。
问答系统:编码器理解给定的问题和背景材料,解码器生成答案。
你只要简单的理解为:Encoder一般是用于提取特征的,Decoder一般是用于生成内容的。那上述的不同任务对应不同的架构,你就非常容易理解了。
下面我们开始针对每个部分展开详细讲解。
(三)Transformer的输入部分
从架构上看,Transformer的输入部分包括嵌入层和位置编码器。
从输入的来源看,Transformer的输入部分包括编码器的输入和解码器的输入(就是上图标注的A和B)。
就是输入部分不仅有源文本的输入还有目标文本的输入。因为这个架构是用来机器翻译的,假如就是中译英吧。所以源文本就是中文文本,目标文本就是英文译文。目标文本我们也叫标签文本。就是输入部分不仅要把中文数据输入到编码器,还得把英文数据输入到解码器。所以我们不仅要对源文本进行embedding和位置编码,还得对英译文也得embeding和位置编码。源文本编码完毕后进入编码器,目标文本编码完毕后进入解码器。二者的嵌入层和位置编码层在架构上都是一样的。
1、嵌入层Input Embedding
上面架构图中输入部分中的Input Embedding、Output Embedding叫嵌入层,或者叫Embedding层。其实你要是学过图像生成、语音生成,你就会发现还有Image Embedding 、Audio Embedding。这些embedding背后的原理都是一样的。在NLP领域,embedding层叫文本嵌入层。Embeding层产生的张量称为词嵌入张量。
注意力机制篇章:【NLP】第五章:注意力机制Attention-CSDN博客 里面已经有部分embedding层的讲解,建议翻看。
(1)无论是源文本嵌入层还是目标文本嵌入层,embedding的本质就是对输入模型的文本进行编码。之前我们一般是用one-hot方法和标签编码(label encoding)方法来编码:
但是从上图可见,标签编码albel encoding(上图的3)会将各个词表示为连续的数值型变量,这样词和词之间就有了大小的区别,而这并不是我们想要的!
而one-hot编码(上图的4)容易导致样本特征向量极度稀疏。我们这才三句话,如果是一本书的语料,字典可能就有数万个词,那此时生成的词向量得多稀疏呀。而且问题是稀疏会带来一系列的麻烦:一是计算的效率低!由于深度学习底层的计算逻辑,让它不善于处理稀疏矩阵,就是稀疏会导致低效!二是计算两个稀疏向量之间的距离效果不好,比如上图中向量"我"和向量"一个"之间的距离,就和向量"我"和向量"梦想"之间的距离相等!这不合情也不合理,还很难优化。
(2)embedding编码技术不同于标签编码和one-hot编码,它是"单射且同构的"。下图我把它的计算过程列出来,我们一起来体会体会:
上面的计算过程就是embedding方法,在NLP中又叫词嵌入技巧word embedding。
(a) nn.Embedding类的第一个参数是字典的长度。第二个参数是你想将词向量编码成几个特征的。就是你想让生成的词向量有几个特征。一般情况都是成本上千、成千上万个特征,我这里为了展示计算过程,就值生成了3个特征。
(b) embedding层参数矩阵是随机生成的。
(c) data中的编码必须从0开始,然后依次加1,并且长度不超过第一个参数,否则会报错!
(d) data必须是Long或者int类型的,如果你用torch.Tensor生成的数据是float32类型,就会报错!
(e) data的数据组织结构最好是(sequence,features),这样生成的词嵌入向量就是(batch, sequence, features),这样此后模型中的batch_size参数就可以设置为True,据说这样的搭配计算效率会提高至少30%以上。
可见,embedding其实就是一种映射方式。将字典中的词汇从一个标量的数字表示形式,先映射为高维one-hot向量,然后再把one-hot向量"单射同构的"映射为词向量的数字表示。这种映射过程是通过矩阵相乘完成的,也就是说可以通过线性层来完成,或者说其实embedding层就是一个线性层,这个线性层的参数矩阵就是lookup table。
(3)我之所以说你把embedding层看成线性层是因为,它和线性层一样,是可以随着训练进行迭代的!
当Embedding层实例化时,它的参数是随机生成的。也就是最开始我们给每个词的编码是随机的,但是每个词的标签编码(就是上图的输入数据data)是固定的。这就是所谓的"单射且同构"的意思。此后随着模型训练迭代,lookup table就会被损失函数牵引着,逐渐迭代到可以很好表示词的语义,也就是这个字典矩阵逐渐被迭代成有意义的一个高维空间,在这个高维空间中的每个词都是有语义的。也就是此时的词向量是有了语义理解的。这才是embedding层的精髓所在。
(4)嵌入层的初始化
训练过神经网络的同学都知道,参数初始化是非常重要的,因为初始化的参数就相当于你模型训练的起点。一个好的起点意味着一个丝滑的迭代过程,如果你的起点就很糟糕,那你的训练过程势必也会很艰难。所以在FNN中我详细介绍了参数初始化的一些方法和理论。感兴趣的可以参考:【深度学习】第六章:模型效果评估与优化_模型评估与优化-CSDN博客
所以同理,要想使我们的模型有一个优秀的起点,我们也应该初始化嵌入层。在实际训练过程中,嵌入层的初始化也是非常重要的。
默认情况下,Pytoch的嵌入层的权重是随机初始化的,但你可以使用预训练好的嵌入向量,例如Word2Vec, Glove等来初始化嵌入层。像Word2Vec,GloVe,FastText等方法,它不仅可以编码词汇,还可以把编码后的词向量赋予语义信息,比如apple和banana之间的距离就小于apple和cat之间的距离。所以建议做初始化嵌入层,以提高模型的准确性和收敛速度。
(5)上图的代码实现是我调用pytorch提供的、用于编码的类nn.Embedding,下面我手动构建一个Embedding类来实现文本嵌入层:
2、位置编码器Positional Encoding
至于transformer中为什么要设置位置编码器,以及transformer使用的正余弦位置编码Sinusoidal的原理和特点,我在 【NLP】第六章:位置编码Positional Encoding-CSDN博客 这篇博文中有详细全面的讲解,大家可以参考。
(1)这里我只想强调位置编码是Transformer架构中最无法被取代的一环,也是目前最有争议的一个环节,也是后人魔改的一环。
比如,在《Attention is all you need》论文里面,作者用了Learned Positional Embedding(让模型自己学位置参数)和Sinusoidal Position Encoding两种方式,得到的结论是两种方法对模型最终的衡量指标差别不大。就是效果没有明显差别呗。但在论文《Encoding Word Oder In Complex Embeddings》中的实验结果表明,使用Complex embedding相较前两种方法有较明显的提升。
但是在后面的BERT中,已经改成用learnable position embedding的方法了,也许是因为positional encoding在进attention层后一些优异性质消失的原因。这也是我的猜想,因为也有人说,即使sinusoidal位置编码本身拥有很好的形式,但位置编码和词嵌入向量相加进入attention模块后,首先进行的是一个线性变换,而这个线性变换就直接导致了位置编码远程衰减这个性质的丢失。意思就是attention的参数矩阵映射后的正余弦波乘积组合并不能表示为若干余弦波的组合,从而缺少单调性,导致Transformer架构无法真正地在计算自注意力矩阵时感知到元素的相对位置信息。对此有人提出将词嵌入向量和位置编码相乘的操作。呃,相乘操作有效吗?....其实网上也有人说相乘也是不行的。。。 。
所以Positional encoding有一些想象+实验+论证的意味,而编码的方式也不只这一种,比如把sin和cos换个位置,依然可以用来编码。最近还比较流行旋转式位置编码,就是在构造查询矩阵和键矩阵时,根据其绝对位置引入旋转矩阵。就是在计算注意力分数前,就先行按照位置顺序,把词向量先行扭转一下,再开始计算注意力分数。这就是在attention上魔改了。
一个有效的、好的位置编码算法是要涉及大量的数学推导的,是要对数学公式有非常敏锐的数感的。为避免陷入纯数学泥潭中,我们各个方法都试试不就行了,毕竟深度学习一定程度上就是炼丹嘛。我这里也主要讲实操,让你先不犯方向性错误的前提下先跑通一个简单任务,然后再琢磨怎么精雕精进,那时才是寻找理论支撑和论证的时候。而且那时理论和实操才会形成良性循环,用理论微修实操,实操佐证理论。
(2)Tranformer论文中是将embedding层的词嵌入向量矩阵和位置编码向量矩阵对应位置上的两个元素相加的。
前面说相乘也不行,那concat呢?至少concat后特征维度直接double,计算量就上来了,这是一个缺点。那相加有缺点吗?我们已经知道Sinusoidal编码中的位置信息主要集中在前面部分的特征维度中,而且Transformer的词向量表示是由embedding层来完成的,embedding层一开始是个随机参数矩阵啊。所以Transformer中的词嵌入是从头开始训练的,所以设置参数的时候,可能不会把单词的语义存储在前几个维度里,这样就避开了位置编码。
论文中作者的意思是,虽然没有直接进行concat,但是相加就是进行了隐式concat。因为位置编码的前半段比较有用,所以在编码嵌入向量的时候,将其语义信息往后方。
所以我们可以相信,最终Transformer是可以将单词的语义与其位置信息分开的。而且也没有理由支持拼接的好处啊,也许相加是目前看较优的选择。
(3)位置编码在模型训练中是不参与迭代的!
位置编码和词向量的表示无关、和词向量携带的语义无关,只与词的位置有关。当我们开始训练模型时,词向量的维度dmodel和n这两个超参数就已经定下来了,也就是位置编码矩阵也定下来了。每个sequence只要按照自己的形状和对应形状的位置编码相加,即可进入attention模块了。正向传播一遍,反向传播求导时,位置编码节点是不参与梯度计算的!只有词嵌入向量的节点参与梯度计算,并以此来更新网络参数。你可以和BN层的running_mean和running_var类比,都是需要保存在模型中的,但是不需要计算梯度的。
pytorch中没有专门实现位置编码的类或者函数,所以这里我用代码实现一下transformer论文中的位置编码器:
import torch
import torch.nn as nnclass PositionEncoding(nn.Module):def __init__(self, d_model, max_len, n=10000, dropout=0):super().__init__()self.dropout = nn.Dropout(p = dropout)pe = torch.zeros(max_len, d_model)seq_idx = torch.arange(0, max_len).unsqueeze(1)f0_idx = torch.arange(0, d_model, 2)f1_idx = torch.arange(1, d_model, 2)pe[:,0::2] = torch.sin(seq_idx/torch.pow(n, f0_idx/d_model))pe[:,1::2] = torch.cos(seq_idx/torch.pow(n, (f1_idx-1)/d_model))pe = pe.unsqueeze(0) #因为后面还要和embedding合并,所以维度增加一维self.register_buffer('pe', pe) #把pe注册成模型的buffer,就可以和参数一同被加载了def forward(self, x):x = x + torch.autograd.Variable(self.pe)return self.dropout(x)max_len = 3
d_model = 4
n = 100
x = torch.zeros(max_len, d_model)
pe = PositionEncoding(d_model = d_model, max_len = max_len, n=n)
pe(x)
说明:不更新梯度不代表不能进行赋值,是可以赋值更新的。见文章最后的补充5部分的示例。
至此Transformer的输入部分我们就讲解完毕,下面开始讲解编码器。
(四)Transformer的编码部分
待续。。。。。
补充:
1、PyTorch中Tensor和tensor的区别
(1)torch.Tensor()是python类,更明确地说,是默认张量类型torch.FloatTensor()的别名,torch.Tensor([1,2])会调用Tensor类的构造函数__init__,生成单精度浮点类型的张量。
(2)torch.tensor()则是python函数,函数原型是:torch.tensor(data, dtype=None, device=None, requires_grad=False), 其中data可以是list, tuple, NumPy ndarray, scalar和其他类型。 torch.tensor会从data中的数据部分做拷贝,而不是直接引用,根据原始数据类型生成相应的torch.LongTensor、torch.FloatTensor和torch.DoubleTensor。
所以,二者底层实现是不一样的。因为函数调用要拷贝参考,而类属性则可以直接引用,所以使用类比使用函数性能会好一点。其他就没必要深究了,建议使用torch.Tensor()。
2、torch.autograd.Variable的用法
将pytorch中的张量封装成Variable对象。Variable对象将张量作为其内部状态,主要作用就是保存张量数据。此外还提供一系列有用的方法和属性来简化神经网络模型的构建和训练过程,使得神经网络编程更加简单、直观和高效。比如:
(1)Variable有requires_grad()属性,可以自动计算梯度(autograd),使得在反向传播过程中能够自动计算损失函数对模型参数的梯度;而volatile=True的节点不会求导,即使requires_grad=True,也不会进行反向传播,对于不需要反向传播的情景,该参数可以实现一定速度的提升,并节省一半的显存,因为其不需要保存梯度。
(2)Variable有save()方法和loadstate_dict()方法,用来保存和恢复模型的状态,使得模型的训练和预测过程可以方便地进行断点续传;
(3)Variable 可以进行数据增强(data augmentation),使得模型能够在训练过程中更好地泛化。
3、pytorch中backward计算梯度的过程
如果Tensor是非标量(non-scalar)的(即是说Y中有不止一个y,即Y=[y1,y2,…]),且requires_grad=True。那么backward函数需要指定gradient,它的形状应该和Variable的长度匹配。因为gradient的长度体与Y的长度一直才能保存每一个yi的梯度值啊。
关于梯度、关于正向传播、反向传播、计算图等这些概念不是特别清楚的同学,建议参考【深度学习】第四章:反向传播-梯度计算-更新参数_反向传播和梯度更新-CSDN博客
4、unsqueeze和squeeze的升维降维
5、nn.Module中register_buffer用法
register_buffer是Module类中的一个方法,用于记录不需要计算梯度但要跟随模型参数一起保存、加载或者移动(cuda)的变量。和BatchNorm中的均值running_mean和方差running_var类似,都是不需要被计算梯度,但是需要保存在模型中。
register_buffer(name, tensor, persistent=True)
name(str):字符串,指定被调用的名字。
tensor(Tensor or None):初始化该注册缓冲器张量,如果为None,则不会保存在模型中,只是暂存。
persistent(default: True):True则可以跟随模型被保存model.save_state_dict()和加载model.load_state_dict()
6、一些计算函数
7、画图技巧