本篇应该是史上最靠谱、最详尽、最本原的一篇、拆解Transformer工作原理以及实现方式的一篇博文。因为不是单靠凭嘴说原理,而是在手动计算数据流层面、在调用pytorch实现层面都进行了完整的拆解和展示。模型的每个环节都从原理、pytorch调包现实、手动计算印证三个层面进行剖析解惑。当然其中某些细节也有我不惑的地方,我都详细说明和记录了,感谢各位帮助解惑,提前谢过!
阅读本文前一定一定要先看注意力机制篇章:【NLP】第五章:注意力机制Attention_nlp 交叉注意力机制 教程-优快云博客
和位置编码篇章:【NLP】第六章:位置编码Positional Encoding_位置编码 随机初始化怎么做-优快云博客
本篇对这俩部分的讲解是掠过的!因为注意力机制是Transformer的核心,position encoding又太难,所以我分别单独开了一个章节先讲透attention和PE这俩部分。所以建议大家先看前两篇文章,这样你就不会出现太多的逻辑断点而无法理解本篇。
七、Transformer原理、计算流程以及pytorch的实现
(一)Transformer简介
2017年,谷歌机器翻译团队发表的《Attention is all you need》论文,以seq2seq架构(编码器-解码器,Encoder-Decoder)为基础、完全基于自注意力机制,提出了全新的Transformer模型架构。该模型抛弃了以往机器翻译基本都会应用的RNN或CNN等传统架构,从架构层面解决了,RNN和CNN无法并行处理,以及无法高效捕捉长距离依赖的问题。
Transformer的两个显著优势:
一是,Tranformer能够利用分布式GPU进行并行训练,提升模型训练效率。Transformer虽然是一个解决时序问题的模型,但它的架构不是像RNN一族那样在时间维度上循环的架构,可以并行训练。二是,在分析预测更长的文本时,捕捉间隔较长的语义关联效果更好。
Transformer诞生之后,就以迅雷不及掩耳之势催生了ELM(华盛顿大学2018年3月提出)、GPT(OpenAI2018年6月提出)、BERT(Google2018年10月提出)、XLNet(CMU+google brain在2019年6月份提出)等等一系列的里程碑式模型。其中,BERT一诞生就直接横扫了NLP领域11个项目任务的最佳成绩!当时风头无二!然而在BERT中发挥重要作用的结构就是Transformer。虽然之后又出现的XLNET,roBERT等模型击败了BERT,但是它们的核心没有变,依然是Transformer。GPT就更不用说了,直接点燃了当前全球狂热的AIGC应用,而GPT底层也是Transformer。
目前,Transformer已经成为了自然语言处理领域绝对主流的网络架构,当前大热的ChatGPT、GPT4、LLaMA、Claude、文心一言等大语言模型(Large Language Model,LLM)都以Transformer或者其变种作为主干网络。Transformer虽然起源于NLP领域,但火爆后,迅速在图像、视频、声音等领域也得到了广泛应用,并且展现出了极其惊艳的效果,成为继承MLP(就是我们之前经常说的FNN或DNN)、CNN、RNN后的公认的第四大基础模型架构。
NLP发展到今天呈现出两条道路:一是基于大语言模型去进行一系列的应用和开发的道路。另外一条是,很多公司试图基于自己的算力、基于tranformer来开发和训练自己的大语言模型,也就是相当于回归到深度学习本身来了。所以transformer就变得更加关键了。transformer不仅可以完成NLP领域研究的典型任务,比如机器翻译、文本生成等,同时又可以构建预训练语言模型,用于不同任务的迁移学习。目前像hugging-face的调用、transformer和其他架构的结合、基于transformer的机器翻译、生成式案例、情感分类、transformer用于词性标注、transformer用于股价预测等等的案例层出不穷,这些应用的底层都是transformer。
(二)Transformer整体架构及数据流
1、Transformer架构各个组成部分
这是论文《Attention is all you need》中的Transformer架构图。这个架构图是用来做翻译任务的。本文假设是中译英任务,就是从中文文本生成英译文文本的任务。
Transformer架构一般分四大部分:
(1)输入部分是将文本数据进行向量化表示的。
输入部分包括:嵌入层Embedding和位置编码器Positional Encoding。
- 嵌入层又包括:编码器的输入Input Embedding和解码器的输入Output Embedding(shifted right)。
- 位置编码器也包括:编码器的位置编码、解码器的位置编码。
(2)编码器部分Encoder,是用来提取中文文本中的细节和特点的,或者说是提取特征的。
编码器部分由N个编码器层串联而成。N是一个超参数,论文中N=6。每个编码器都由两个子层连接而成:
- 第一个子层包括一个多头自注意力层Multi-Head Attention、一个残差连接shortcut connection、一个规范化层Norm。
- 第二个子层包括一个前馈全连接Feed Forward、一个残差连接、一个规范化层。
(3)解码器部分Decoder,是用来生成新内容的。
编码器在训练阶段和测试阶段,它生成新内容的方式是不一样的。在训练阶段,它是在损失函数的牵引下,朝着标签文本生成的方向迭代的。
在测试阶段是以自回归的方式,生成新内容的。具体到翻译任务就是生成英文文本序列的。自回归的意思是模型在生成每个新单词时都依赖于之前已生成的单词序列。简言之,就是模型在测试阶段时,它预测下一个输出,会使用到目前为止已经生成的所有输出作为上下文信息。这也是为什么生成模型一般都是一个字一个字往外蹦的原因。
解码器部分也是由N个解码器层串联而成。N=6。每个解码器由三个子层连接而成:
- 第一个子层包括一个带掩码的多头自注意力层Masked Multi-Head Attention、一个规范化层、一个残差连接。
- 第二个子层包括一个多头交叉注意力层Multi-Head Attention、一个规范化层、一个残差连接。
- 第三个子层包括一个前馈全连接子层、一个规范化层、一个残差连接。
(4)输出部分是用来预测每个英文单词的概率的。输出部分包括一个全连接层线性层Linear+Softmax变换。
就是你的英文字典有多少个单词,那这个Linear线性层就有多少个神经元,就会输出多少个Output Probabilities,然后选取概率最大的那个单词作为输出即可。
Transformer是一个深层网络。上图仅仅画出来的是1个编码器和1个解码器。这个架构是可以加深的,在Transformer论文中N=6,就是依次串联6个编码器,同理解码器也是依次串联6个解码器。也就是说Transformer架构其实是一个深层网络架构。在深度学习史上第一个深度网络是残差网络:【深度视觉】第十章:复现SOTA 模型:ResNet_视觉lstm的sota-优快云博客 达到100层以上。这篇博文中有详细描述网络深度的意义以及深度带来的问题和解决对策,感兴趣的可以查阅。
2、Transformer的具体架构是随任务而定的
在NLP领域中,不同的任务是需要不同的Transformer架构的,就是不同任务对应不同的架构搭建。所以我们需要根据不同的NLP任务需求,来组合搭建Transformer中的各个部件,以适应不同的应用场景:
(1)只是使用编码器的任务:
编码器的任务是从输入数据中提取特征。编码器通常用于不需要生成新序列的任务,比如:
文本分类:如情感分析、垃圾邮件检测、文本分类(bert)等,输入一个文本序列,编码器提取特征后进行分类。
命名实体识别(Named Entity Recognition,NER):在给定文本中识别实体(如人名、地名等),这也是分类问题的一种,可以用编码器提取文本的特征。
句子相似度:判断两个句子是否相关或相似度如何,可以通过编码器提取句子特征后计算相似度。
(2)只使用解码器的任务:
解码器专注于生成新序列,通常使用自回归方式,基于之前的输出生成下一个词。适用于:
文本生成:如GPT系列,只使用解码器生成文本,例如故事续写、文章生成等。
文本续写或预测:训练解码器来预测下一个单词或字符。
(3)编码器和解码器都使用的任务:
当任务涉及到理解输入序列并生成新的输出序列时,编码器和解码器会联合使用。就是序列到序列的任务,比如:
机器翻译:编码器负责理解源语言,解码器负责生成目标语言。
文本摘要:编码器理解原始文本,解码器生成摘要。
问答系统:编码器理解给定的问题和背景材料,解码器生成答案。
你只要简单的理解为:Encoder一般是用于提取特征的,Decoder一般是用于生成内容的。所以上面的架构图是用于机器翻译任务的,就是从一种语言文本到另一种语言文本的转换,或者说是从一个序列到另一个序列的映射任务。本文假设是中译英任务,就是从中文文本生成英译文文本的任务。
3、Transformer的数据流
上面只是一个大概的数据流,具体数据每流过一个模块到底是怎么发生变换的,后面我们还要对每个环节进行细化分析。
(三)输入部分
1、明确一些名称
前面已经反复强调过了,上面的架构是用来做翻译任务的架构,并且假定是中译英任务。那么这个模型的Inputs就是中文文本,又叫源文本。Outputs(shifted right) 就是对应的英译文文本,又叫目标文本。其中标注的shifted right操作,是指给目标文本添加一个起始符(起始标识符),这样看似好像是shifted right了。至于为什么要shifted right,后面讲数据以及数据流时,你可以直观地看到原因。
补充:上图解码器的顶端还有个Output Probabilities,这个是输出部分生成的英文词典中所有单词的概率,这个概率向量是要和标签文本计算交叉熵损失的。有了损失->反向传播计算梯度->更新网络参数,也就是训练模型的过程,也就是模型学习的过程。那标签文本又是什么?
标签文本和目标文本一样,就是英译文文本,都是来自英文字典。
但是目标文本还需要embedding变成词向量,而标签文本只是字典中的token的编码形态。因为二者的使用目的不同嘛,目标文本是要输入解码器的,标签文本是用来计算损失的。 目标文本需要有一个"开始"的标志。标签文本不需要"开始"标识但需要"结束"标识。至于为什么要这样设计,后面还讲mask以及数据流时你自然就体会到了。
2、编码器的输入、解码器的输入
从上面架构图看,Transformer的输入是包括编码器的输入(Input Embedding)和解码器的输入(Outputs(shifted right))两部分的。就是输入不仅有源文本的输入还有目标文本的输入,其中源文本输入编码器,目标文本输入解码器。
我们知道源文本就是中文文本,中文文本输入编码器是用来提取中文文本中的特征的。那英文文本进入解码器是要干什么的?这不是给解码器看答案嘛?!
(1):在上面架构中,英文文本是进入解码器中的带掩码的多头注意力模块(上图D),这个层又叫因果自注意力层(Casual attention layer),为什么有这个叫法:
首先模块D是一个自注意力模块,所以这个模块也是提取英文文本中的特征的,就是提取英文文本中的细节和重点的。为什么要提取英文文本的特征?
一方面是可以给模型一些预测信息,让解码器预测难度降低一些。英文文本是标签文本呀,标签进入解码器就相当于是给解码器一些生成的提示信息,让预测难度小一点。这和CGAN的原理类似:【深度视觉】第十六章:生成网络4——条件GAN之cGAN、SGAN、ACGAN、infoGAN、LAPGAN-优快云博客 感兴趣的可以查阅。
另一方面则是因为,英文文本是用于计算Q的!就是这个模块是要计算"送入交叉注意力模块"的Q的,让Q去匹配K(V)的。这是Transformer的精华和灵魂所在!从一个模态(中文)转换到另外一个模态(英文),而在这个模态的转换过程中,是需要英文作为Q,让Q去寻找中文token里面最大注意力的K的,所以叫D模块叫因果注意力层。
其次模块D中是带有掩码的。那什么是掩码?为什么要掩住?
掩码其实就是一个矩阵。这个矩阵的结构和目标文本的结构一致,但是是一个上三角,是用来遮掩答案的。
标签文本进入解码器是提供提示信息的,并不是直接告诉模型答案的!所以需要掩码矩阵进行遮掩。
但是查阅网上各种资料,大家普遍的解释是:解码器是根据编码器提取的特征,自回归式的生成新序列英译文的,所以当模型要预测第一个英译单词时,那掩码矩阵是要把标签文本全部遮住的。当模型已经预测完毕第一个单词、开始预测第二个单词时,掩码矩阵就把第一个单词显示出来,这样解码器就知道了第一个标签答案,然后模型就可以参考第一个标签单词预测第二个单词了。第三个单词、第四个单词、、、依次类推。这也是为什么我们经常看到一些生成模型是一个单词一个单词地往出蹦的形式生成的,就是它预测下一个单词是要参考(或者说基于)前面所有已经预测完毕的单词的,也就是为什么模型是自回归的方式来预测的。
网上的资料普遍都是这个说辞,而且还画了各种流程图来解释这个事情。其实大家在讲的时候都没有说清楚前提,这个前提是模型处于测试阶段。当模型处于训练阶段,你把每个细节的数据流都展开,你会发现掩码的作用其实是为了让Q更纯粹,不让Q参杂已知的事情。因为Q本来就是答案嘛,你又是拿着答案去询问,就是你又是答案又是问题,那只能让你shifted right一个位置,并且用上三角掩住,这样你就可以是一个更存粹的Q了。比如Q是样本1,那Q中不能有sequence中的其他样本信息。如果Q是样本2,那Q中只能有样本1的信息,就是Q只能携带着样本1的信息去询问K(V)。依次类推。
(2):英文文本的数据流流到f'3时,f'3要拷贝一份,这份(f'3)是要和中文文本的特征(编码器的流出f5)一起计算两个模态之间的注意力分数的,也就是上图的E:交叉注意力模块,实现中英文序列之间的转换的,也就是实现从一个模态的序列(中文)到另外一个模态的序列(英文)之间的转换。
总之,编码器的输入是为了提取中文特征的。解码器的输入,一方面是为了给解码器提供额外信息,另一方面是为了生成Q,用Q匹配K(V),逼迫解码器的生成内容向Q的最优答案无限靠近。所以我们是需要给解码器输入目标文本的。
上面的表述你看后可能会很迷惑和混乱,这是正常的。因为我是站在已经非常了解Transformer的前因后果、各个细节的基础上总结的。当你还不太了解每个细节,你就会有逻辑断点,所以没法理解也正常。后面我还要详解拆解D、E、掩码等细节,你带着这些问题继续后面的学习,你就有更加深刻的理解和体会了。
3、Embedding词嵌入
既然中文文本需要输入编码器,英文文本也需要输入解码器,而不管是编码器还是解码器,它们都不认识文本数据的,它们只认识向量、矩阵等数据,所以需要把中文文本和英文文本都转化为词向量。
将文本数据转化为词向量有很多方式,但在Transformer架构中使用的是的embedding词嵌入。就是不管是中文文本还是英文文本,都是使用Embedding层转化为词向量。
Transformer架构图中的Embedding层叫嵌入层。你要是学过图像生成、语音生成,你就会发现还有Image Embedding 、Audio Embedding。这些embedding背后的原理都是一样的。在NLP领域,embedding层又叫文本嵌入层。Embeding层产生的张量称为词嵌入张量。
注意力机制篇章:【NLP】第五章:注意力机制Attention_nlp 交叉注意力机制 教程-优快云博客 里面已经有部分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中我详细介绍了参数初始化的一些方法和理论。感兴趣的可以参考:【深度学习】第六章:模型效果评估与优化_模型评估与优化-优快云博客
所以同理,要想使我们的模型有一个优秀的起点,我们也应该初始化嵌入层。在实际训练过程中,嵌入层的初始化也是非常重要的。
默认情况下,Pytoch的嵌入层的权重是随机初始化的,但你可以使用预训练好的嵌入向量来初始化嵌入层,像Word2Vec,GloVe,FastText等方法不仅可以编码词汇,还可以把编码后的词向量赋予语义信息,比如apple和banana之间的距离就小于apple和cat之间的距离。所以建议做初始化嵌入层,以提高模型的准确性和收敛速度。
4、位置编码器Positional Encoding
至于transformer中为什么要设置位置编码器,以及transformer使用的正余弦位置编码Sinusoidal的原理和特点,我在 【NLP】第六章:位置编码Positional Encoding_位置编码 随机初始化怎么做-优快云博客 这篇博文中有详细全面的讲解,大家可以参考。
(1)这里我只想强调位置编码是Transformer架构中最无法被取代的一环,也是目前最有争议的一个环节,也是后人魔改的一环。
比如,在《Attention is all you need》论文里面,作者用了Learned Positional Embedding(让模型自己学位置参数)和Sinusoidal Position Encoding两种方式,得到的结论是两种方法对模型最终的衡量指标差别不大。就是效果没有明显差别呗。但在论文《Encoding Word Oder In Complex Embeddings》中的实验结果表明,使用Complex embedding相较前两种方法有较明显的提升。
但是在后来的BERT中,BERT已经改成用了learnable position embedding的方法,也许是因为positional encoding在进attention层后一些优异性质消失的原因。这也是我的猜想,因为也有人说,即使sinusoidal位置编码本身拥有很好的形式,但位置编码和词嵌入向量相加进入attention模块后,首先进行的是一个线性变换,而这个线性变换就直接导致了位置编码远程衰减这个性质的丢失。意思就是attention的参数矩阵映射后的正余弦波乘积组合并不能表示为若干余弦波的组合,从而缺少单调性,导致Transformer架构无法真正地在计算自注意力矩阵时感知到元素的相对位置信息。对此有人提出将词嵌入向量和位置编码相乘的操作。呃,相乘操作有效吗?....其实网上也有人说相乘也是不行的。。。 。
所以Positional encoding这个环节是有一些想象+实验+论证的意味,而且编码的方式也不只这一种,比如把sin和cos换个位置,依然可以用来编码。最近还比较流行旋转式位置编码,就是在构造查询矩阵和键矩阵时,根据其绝对位置引入旋转矩阵。就是在计算注意力分数前,就先行按照位置顺序,把词向量先行扭转一下,再开始计算注意力分数。这就是在attention上的魔改了。
anywary,总之一个有效的、好的位置编码算法是要涉及大量的数学推导的,是要对数学公式有非常敏锐的数感的。为避免陷入纯数学泥潭中,我们各个方法都试试不就行了,毕竟深度学习一定程度上就是炼丹嘛。我这里也主要讲实操,让你先不犯方向性错误的前提下先跑通一个简单任务,然后再琢磨怎么精雕精进,那时才是寻找理论支撑和论证的时候。而且那时理论和实操才会形成良性循环,用理论微修实操,实操佐证理论。
(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 nn
class 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部分的示例。
5、编造一个玩具数据集,实现Transformer的输入部分
上面都是输入部分的理论分析,下面我们实操一个Transformer的小型输入数据。
对于一个机器翻译任务的输入,如果要从数据集说起,那你还得解决语言对齐、词汇对齐、短语对齐、句子对齐等等一系列的问题,这是一个big big topic。所以这里我们也是不跑远,我们就编造一个玩具数据集,展示一下从数据集->f1这个输入部分的流程:
(1)收集文本数据
一是,在中英翻译任务中,训练集中不仅要有中文文本还得有对应的英译文文本,而且中文和英文是要一一对应的。
二是,对中、英文文本分别进行分词。
三是,中文文本用来提取特征的。英译文文本用来制作目标文本和标签文本的,其中,目标文本用于解码器的输入,标签文本用于计算损失。
四是,输入解码器的英译文最开始要加一个标志序列开始的起始标识,比如上图,我加的是"S",这个你随意,有的加<sos>或者<bos> start of sequence, begin of seqence.
加起始标识的原因是:一是给模型一个起始的标志,二是加起始符的目标文本和上三角mask相加,第一个露出来的就是起始符,模型就可以根据这个起始符来预测第一个单词。当预测第二个单词时,就把起始符和已经预测完毕的第一个单词暴露出来,来预测第二个单词了。所以这个shifted right操作其实是很巧妙的,它是要配合后面的mask的。这里看不懂没关系,后面讲数据流时还会展示,到时你就会直观的看到了。
五是,用于计算损失的标签文本要在最后加一个标志序列结束的结束标识,比如上图我加的"E"。有的加<eos> end of sequence. anyway,你随意。
结束标识符的功能就仅仅是给模型一个结束生成的信号而已。
六是,中文文本以某个长度sequence_lens1作为所有句子的长度,包括P标识符。比如上图就是以6为sequence lens,超出这个长度的句子截断,小于这个长度的句子后面用padding补齐。这个长度一般以我们收集的中文文本数据的平均长度为长度。
七是,英文文本也以某个长度sequence_lens2作为所有英文句子的长度,包括P\S\E三个标识符。目标文本和标签文本都以这个长度为标准,长度不够的padding补齐,长度超过的截断。
八是,sequence_len1可以和sequence_lens2的大小不一样。这个长度是未来的序列长度。中文序列和英文序列的长度是可以不一样的。我上图的例子这两个长度一样是巧了。
(2)构建字典
分完词后去重->去重后就是字典喽。
所以中文语料和英文语料一样,都是先分词->去重->构建字典。所以,中文字典的长度是可以和英文字典的长度不一致的。上图中我们是恰巧一致了。
中文字典中要包括padding标识符,并且最好放在第一个位置。英文字典中要包括S\E\P三个标识符,也是最好把P放到第一个位置。其中,P标识符我们后续制作mask要用到,一是把P设置为0方便制作mask;二是把p设置为0,后面求交叉熵损失时,也方便指定ignoreindex,因为解码器顶端的输出Output Probabilities,就是英文字典的所有token的probability。哪个token的概率最大,模型预测的生成就是那个token代表的单词,所以我们在计算损失时需要把padding的样本给忽略掉。
(3)构建可以输入Embedding层的标签编码
就是按照字典把中、英文本都转化为那个字、词、或者标识的标签编码。
一个词对应一个唯一的数字,一句话就对应一个向量。此时中文文本的每句话长度都是一致的。英文目标文本和英文标签文本的每句话长度也是一致的。
(4)进行embedding词嵌入
中文文本有它自己的embedding层,英文文本也有它自己的embedding层,中英文不能共享paddding层!也就是中文有中文字典映射的lookup table, 英文有英文字典映射的lookup table。但是二者的特征个数features_lens必须必须一致!!因为后面算注意力分数时,算的就是词向量之间的点积。所以必须要求两个词向量的长度是一致的。就是中英文每个sequence lens可以不一样,但每个样本的features_lens必须一样。
这里还有3个小注意点:
一是,中文文本词嵌入时一定记得设置padding的编码为0。英文文本的词嵌入也是把padding设置为0。这样在后面制作mask时要方便一点。
二是,最好二者的随机数设置不一样。虽然训练过程中二者都是要进行迭代的,但是这里设置的不一样后面展示数据流的时候会清晰一点。
三是,标签文本就不需要embedding了,这是根据任务的需要的。标签文本是用来最最后计算交叉熵损失使用的,而计算交叉熵损失只要标签编码即可。
(5)添加位置编码
每次反向传播PE是不迭代的。PE就是一个不变的参数。每轮训练PE都是上面的方式并入数据的。
加入位置编码后,P\S\E都变了,这个先不用搭理,因为这些标识符后面mask的时候才处理,这里变就变吧。
小结:汇总上面的代码
#1、训练集:
train_sentences = [['我 有 一 个 好 朋友', 'S I have a good friend', 'I have a good friend E'],
['这 是 一 只 猫 P', 'S This is a cat P', 'This is a cat P E'],
['我 喜欢 猫 P P P', 'S I like cat P P', 'I like cat P P E']]
train_sentences #len(sentences)=3
# 测试集:(我希望模型达到的效果)
# 输入:"我有一只猫"
# 输出:"I have a cat"
#2、建立字典:中文和英文单词要分别建表
src_vocab = {'P':0, '我':1, '有':2, '一':3, '个':4, '好':5, '朋友':6, '这':7, '是':8, '只':9, '猫':10, '喜欢':11}
src_vocab_size = len(src_vocab) #12
#src_idx2word = {i: w for i, w in enumerate(src_vocab)}
#src_idx2word
tgt_vocab = {'P':0, 'S':1, 'E':2, 'I':3, 'have':4, 'a':5, 'good':6, 'friend':7, 'This':8, 'is':9, 'cat':10, 'like':11}
tgt_vocab_size = len(tgt_vocab) #12
#tgt_idx2word = {i: w for i, w in enumerate(tgt_vocab)}
#tgt_idx2word
#3、构造数据可以输入Embedding层的标签编码
len(train_sentences) #3
enc_inputs, dec_inputs, dec_outputs = [], [], []
for i in range(len(train_sentences)):
enc_input = [src_vocab[j] for j in train_sentences[i][0].split()]
enc_inputs.append(enc_input)
dec_input = [tgt_vocab[j] for j in train_sentences[i][1].split()]
dec_inputs.append(dec_input)
dec_output = [tgt_vocab[j] for j in train_sentences[i][2].split()]
dec_outputs.append(dec_output)
enc_inputs = torch.LongTensor(enc_inputs)
dec_inputs = torch.LongTensor(dec_inputs)
dec_outputs = torch.LongTensor(dec_outputs)
#4、进行词嵌入
embedding_dim = 6
torch.random.manual_seed(1)
src_embedding = nn.Embedding(src_vocab_size, embedding_dim, padding_idx=0)
src_emb = src_embedding(enc_inputs)
torch.random.manual_seed(2)
tgt_embedding = nn.Embedding(tgt_vocab_size, embedding_dim, padding_idx=0)
tgt_emb = tgt_embedding(dec_inputs)
src_emb, tgt_emb
#5.1 生成位置编码
max_len = 6
d_model = 6
n = 100
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.shape
#5.2 添加位置编码
f1_enc = src_emb + pe
f1_enc.round(decimals=2)
f1_dec = tgt_emb + pe
f1_dec.round(decimals=2)
至此我们的输入部分就整理完毕。我们的输入就是f1_enc和f1_dec:
待续。。。。
我也没想到文章越写越长,这篇文章已经1.5万字了,但似乎只写了个开头。。。后面还有很多,所以打算再开两个章节写。欢迎围观下个章节:
【NLP】第八章:Transformer原理、计算流程以及代码实现-2-优快云博客
补充:
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的梯度值啊。
关于梯度、关于正向传播、反向传播、计算图等这些概念不是特别清楚的同学,建议参考【深度学习】第四章:反向传播-梯度计算-更新参数_反向传播和梯度更新-优快云博客
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、画图技巧