
pad是什么
-
2023年2月19日发(作者:)机器翻译模型Transformer代码详细解析
⾕歌⼀个⽉前发了⼀篇论⽂,⽂中提出了⼀种新的架构叫做Transformer,⽤以来实现机器翻译。它抛弃了传统⽤CNN或者RNN的定式,取
得了很好的效果,激起了⼯业界和学术界的⼴泛讨论。本⼈的另⼀篇博客也对改论⽂进⾏了⼀定的分析:。⽽在⾕歌的论⽂发出不久,就有
⼈⽤tensorflow实现了Transformer模型:。这⾥我打算对该开源实现的代码进⾏细致的分析。
该实现相对原始论⽂有些许不同,⽐如为了⽅便使⽤了IWSLT2016德英翻译的数据集,直接⽤的positionalembedding,把learning
rate⼀开始就调的很⼩等等,不过,主要模型没有区别。
该实现⼀共包括以下⼏个⽂件
该⽂件包含所有需要⽤到的参数
该⽂件⽣成源语⾔和⽬标语⾔的词汇⽂件。
data_该⽂件包含所有关于加载数据以及批量化数据的函数。
该⽂件具体实现编码器和解码器⽹络
训练模型的代码,定义了模型,损失函数以及训练和保存模型的过程
评估模型的效果
接下来针对每⼀个⽂件分别解析。
⾸先是⽂件。
该实现所⽤到的所⼜的超参数都在这个⽂件⾥⾯。以下是该⽂件的所有代码:
classHyperparams:
\'\'\'Hyperparameters\'\'\'
#data
source_train=\'corpora/\'
target_train=\'corpora/\'
source_test=\'corpora/\'
target_test=\'corpora/\'
#training
batch_size=32#alias=N
lr=0.0001#r,learningrateisadjustedtotheglobalstep.
logdir=\'logdir\'#logdirectory
#model
maxlen=10#=T.
#Feelfreetoincreasethisifyouareambitious.
min_cnt=20#wordswhoseoccurredlessthanmin_cntareencodedas.
hidden_units=512#alias=C
num_blocks=6#numberofencoder/decoderblocks
num_epochs=20
num_heads=8
dropout_rate=0.1
可以看出该部分没有什么特别难以理解的,定义了⼀些要使⽤的超参数以便以后使⽤。⾸先是源语⾔以及⽬标语⾔的训练数据和测试数据的
路径,其次设定了batch_size的⼤⼩以及初始学习速率还有⽇志的⽬录,batch_size在后续代码中即所谓的N,参数中常会见到。最后定义
了⼀些模型相关的参数,maxlen为⼀句话⾥最⼤词的长度为10个,在其他代码中就⽤的是T来表⽰,你也可以根据⾃⼰的喜好将这个参数
调⼤;min_cnt被设置为20,该参数表⽰所有出现次数少于min_cnt次的都会被当作UNK来处理;hidden_units设置为512,隐藏节点的个
数,在代码中⽤C来表⽰。num_blocks和num_heads都是论⽂中提到的设定,epoch⼤⼩设置为20,此外还有dropout就不⽤多费⼝⾆
了。
以上就是该开源实现中超参数的设定,该部分,没有太多可以说的。
,该代码的作⽤是⽣成源语⾔和⽬标语⾔的词汇⽂件。
为了直观理解,⾸先看⼀下执⾏代码之后⽣成的词汇⽂件是啥样的,我这⾥截取了德语词汇⽂件的前⼏⾏:
1000000000
1000000000
1000000000
1000000000
die85235
und77082
der56248
ist51457
可以看出,⽂件把训练数据中出现的单词和其出现的次数做了统计,并且记录在⽣成的词汇⽂件中。第⼀列为单词,第⼆列为出现的次数。
同时,设置了四个特殊的标记符号,把他们设定为出现次数很多放在⽂件的最前。
仍然是先贴代码。
from__future__importprint_function
fromhyperparamsimportHyperparamsashp
importtensorflowastf
importnumpyasnp
importcodecs
importos
importregex
fromcollectionsimportCounter
defmake_vocab(fpath,fname):
\'\'\'Constructsvocabulary.
Args:
fpath:ilepath.
fname:filename.
Writesvocabularylinebylineto`preprocessed/fname`
\'\'\'
text=(fpath,\'r\',\'utf-8\').read()
text=(\"[^sp{Latin}\']\",\"\",text)
words=()
word2cnt=Counter(words)
(\'preprocessed\'):(\'preprocessed\')
(\'preprocessed/{}\'.format(fname),\'w\',\'utf-8\')asfout:
(\"{}t1000000000n{}t1000000000n{}t1000000000n{}t1000000000n\".format(\"\",\"\",\"\",\"\"))
forword,_common(len(word2cnt)):
(u\"{}t{}n\".format(word,cnt))
if__name__==\'__main__\':
make_vocab(_train,\"\")
make_vocab(_train,\"\")
print(\"Done\")
代码中make_vocab函数就是⽣成词汇⽂件的函数。该函数⼀共有两个参数,fpath表⽰输⼊⽂件的路径,具体⽽⾔就是训练数据,⽽另⼀
个参数fname即要输出的词汇⽂件名。
该函数⼀⾏⼀⾏地将词汇写⼊到’preprocessed/fname’中。
可以注意到⼀开始使⽤codecs中的open函数来打开并读取⽂件的。那么这个和我们平常使⽤的open函数有什么区别呢?基本上在处理语
⾔的时候都要在unicode这种编码上边搞,可以看到的时候直接将⽂件爱呢转换为内部unicode,其中第三个参数就是源⽂件
的编码格式。关于codecs具体可以参考。
读取⽂件之后⽤正则表达式对读⼊的数据进⾏了处理,sub函数⽤于替换字符串中的匹配项,⼀共有三个参数,将第三个参数所代表的字符
串中等所有满⾜第⼀个参数⽰例的形式的字符都⽤第⼆个参数来代替。
接下来将读取的⽂本按照空⽩分割成words之后放⼊Counter进⾏计数,计数的结果类似于⼀个字典,key为词,value为出现的次数。然后
创建爱呢保存预处理⽂件的⽬录。同样利⽤codecs李的open函数创建⼀个要输出的⽂件,⾸先将四个准备好的特殊词写⼊⽂件在开始的四
⾏。然后利⽤most_common函数依词出现的频率将训练集中出现的词和其对应的计数⼀⾏⼀⾏写⼊⽂件。
分别⽤德语和英语⽂件作为参数运⾏该函数即可得到词汇⽂件。
接下来分析第三个⽂件data_,该⽂件包含所有关于加载数据以及批量化数据的函数。还是先上代码。
from__future__importprint_function
fromhyperparamsimportHyperparamsashp
importtensorflowastf
importnumpyasnp
importcodecs
importregex
defload_de_vocab():
vocab=[()[0](\'preprocessed/\',\'r\',\'utf-8\').read().splitlines()ifint(()[1])>=_cnt]
word2idx={word:idxforidx,wordinenumerate(vocab)}
idx2word={idx:wordforidx,wordinenumerate(vocab)}
returnword2idx,idx2word
这⾥⼀部分⼀部分代码进⾏分析。
⾸先是load_de_vocab()函数。该函数的⽬的是给德语的每个词分配⼀个id并返回两个字典,⼀个是根据词找id,⼀个是根据id找词。函数
直接利⽤codecs的open来读取之前在预处理的时候⽣成的词汇⽂件。注意这⾥读每⾏的时候去掉了那些出现次数少于_cnt(根据设
定为20)的词汇。读完之后有⼀个词汇列表。然后便利该列表的枚举enumerate(vocab)⽣成词和其对应id的两个字典。
接下来是load_en_vocab()函数的代码:
defload_en_vocab():
vocab=[()[0](\'preprocessed/\',\'r\',\'utf-8\').read().splitlines()ifint(()[1])>=_cnt]
word2idx={word:idxforidx,wordinenumerate(vocab)}
idx2word={idx:wordforidx,wordinenumerate(vocab)}
returnword2idx,idx2word
该函数和之前的⽣成德语word/id字典的函数⼀样,只不过⽣成的是英语的word/id字典,⽅法都⼀样,不⽤多说。
接下来是creat_data函数。
defcreate_data(source_sents,target_sents):
de2idx,idx2de=load_de_vocab()
en2idx,idx2en=load_en_vocab()
#Index
x_list,y_list,Sources,Targets=[],[],[],[]
forsource_sent,target_sentinzip(source_sents,target_sents):
x=[(word,1)forwordin(source_sent+u\"\").split()]#1:OOV,:EndofText
y=[(word,1)forwordin(target_sent+u\"\").split()]
ifmax(len(x),len(y))<=:
x_((x))
y_((y))
(source_sent)
(target_sent)
#Pad
X=([len(x_list),],32)
Y=([len(y_list),],32)
fori,(x,y)inenumerate(zip(x_list,y_list)):
X[i]=(x,[0,-len(x)],\'constant\',constant_values=(0,0))
Y[i]=(y,[0,-len(y)],\'constant\',constant_values=(0,0))
returnX,Y,Sources,Targets
该函数⼀共有两个参数,source_sents和target_sents。可以理解为源语⾔和⽬标语⾔的句⼦列表。每个列表中的⼀个元素就是⼀个句
⼦。
⾸先利⽤之前定义的两个函数⽣成双语语⾔的word/id字典。
同时遍历这两个参数指⽰的句⼦列表。⼀次遍历⼀个句⼦对,在该次遍历中,给每个句⼦末尾后加⼀个⽂本结束符⽤以表⽰句⼦末尾。加
上该结束符的句⼦⼜被遍历每个词,同时利⽤双语word/id字典读取word对应的id加⼊⼀个新列表中,若该word不再字典中则id⽤1代替
(即UNK的id)。如此则⽣⾠概率两个⽤⼀串id表⽰的双语句⼦的列表。然后判断这两个句⼦的长度是否都没超过设定的句⼦最⼤长度
,如果没超过,则将这两个双语句⼦id列表加⼊模型要⽤的双语句⼦id列表x_list,y_list中,同时将满⾜最⼤句⼦长度的原始句⼦
(⽤word表⽰的)也加⼊到句⼦列表Sources以及Targets中。
函数后半部分为Pad操作。关于numpy中的pad操作可以参考。这⾥说该函数的pad运算,由于x和y都是⼀维的,所有只有前后两个⽅向可
以pad,所以pad函数的第⼆个参数是⼀个含有两个元素的列表,第⼀个元素为0说明给x或者y前⾯什么也不pad,即pad上0个数,第⼆个
元素为-len(x)以及-len(x)代表给x和y后⾯pad上x和y初始元素个数和句⼦最⼤长度差的那么多数值,⾄于pad成什么
数值,后⾯的constant_values给出了,即pad上去的id值为0,这也是我们词汇表中PAD的id。经过pad的操作可以保证⽤id表⽰的句⼦列
表都是等长的。
最终返回等长的句⼦id数组X,Y,以及原始句⼦李标Sources以及Targets。X和Y的shape都为[len(x_list),]。其中len(x_list)
为句⼦的总个数,为设定的最⼤句⼦长度。
接下来有⼀个函数为load_train_data(),还是上代码:
defload_train_data():
de_sents=[(\"[^sp{Latin}\']\",\"\",line)(_train,\'r\',\'utf-8\').read().split(\"n\")iflineandline[0]!=\"<\"]
en_sents=[(\"[^sp{Latin}\']\",\"\",line)(_train,\'r\',\'utf-8\').read().split(\"n\")iflineandline[0]!=\"<\"]
X,Y,Sources,Targets=create_data(de_sents,en_sents)
returnX,Y
,该函数的作⽤是加载训练数据,加载的⽅式很简单,就是加载刚才create_data返回的等长句⼦id数组。load_train_data的作⽤只不过是
给create_data提供了de_sents和en_sents两个参数⽽已。
⽽de_sents和en_sents这两个句⼦列表同样是通过codecs⾥的open读取训练数据⽣成的。读取之后按照换⾏符n分隔开每⼀句,在这些
句⼦中选择那些那些⾏开头符号不是‘<’的句⼦(句⾸为<是数据描述的⾏,并⾮真实数据的部分)。在这些分离好的句⼦中同样⽤正则表达
式进⾏处理。
接下来是load_test_data()函数。
defload_test_data():
def_refine(line):
line=(\"]+>\",\"\",line)
line=(\"[^sp{Latin}\']\",\"\",line)
()
de_sents=[_refine(line)(_test,\'r\',\'utf-8\').read().split(\"n\")iflineandline[:4]==\" en_sents=[_refine(line)(_test,\'r\',\'utf-8\').read().split(\"n\")iflineandline[:4]==\" X,Y,Sources,Targets=create_data(de_sents,en_sents) returnX,Sources,Targets#(1064,150) load_test_data和load_train_data类似,区别不⼤。是⽣成测试数据源语⾔的id表⽰的定长句⼦列表(⽬标语⾔由模型预测不⽤⽣成), 同时还有源语⾔和⽬标语⾔原始句⼦列表。 区别在与正在表达式的操作有些许不同,其中⽤到了⼀个函数strip(),默认参数的话就是去掉字符串⾸以及末尾的空⽩符。同时数据⽂件中每 ⾏以\" 最后就是get_batch_data()函数,⽤以依次⽣成⼀个batch的数据。 defget_batch_data(): #Loaddata X,Y=load_train_data() #calctotalbatchcount num_batch=len(X)//_size #Converttotensor X=t_to_tensor(X,32) Y=t_to_tensor(Y,32) #CreateQueues input_queues=_input_producer([X,Y]) #createbatchqueues x,y=e_batch(input_queues, num_threads=8, batch_size=_size, capacity=_size*64, min_after_dequeue=_size*32, allow_smaller_final_batch=False) returnx,y,num_batch#(N,T),(N,T),() ⾸先利⽤之前定义的load_train_data加载训练数据,即源语⾔和⽬标语⾔id表⽰的句⼦数组。然后将这两个数组转化为tensorflow⽀持的 tensor这种数据类型,其中dtype为32。此时,X和Y的shape还是[句⼦总数,]。接下来⽤⼀个函数 _input_producer对训练数据进⾏处理,该函数从输⼊中每次取⼀个切⽚返回到⼀个输⼊队列⾥,该队列作为之后 e_batch的⼀个参数,⽤以⽣成⼀个batch的数据。关于_input_producer以及e_batch可参 照,⾥⾯有详细的介绍和参数选取的⽅法。 最后返回e_batch所⽣成的⼀个batch的数据x,y以及num_batch,表⽰⼀共得多少各batch才能把数据表⽰完。其中x,y 的shape为[N,T],N即batch_size的⼤⼩,T为最⼤句⼦长度maxlen,默认为10。 以上介绍了三个⽐较简单的代码⽂件,超参数,数据与处理以及准备batch数据的⽂件,接下来三个是重中之重,分别是模型主要⽹络构 建,⽹络的结构,训练以及评估的代码。 ,该⽂件具体实现编码器和解码器⽹络,论⽂attentionisallyouneed具体的attention结构就是由这个模块所定义。 下⾯仍然按照之前的习惯⼀部分⼀部分代码进⾏分析。 第⼀部分代码是实现layernormalizition的功能,因为论⽂中提到会把数据做⼀个layernormalizition,后⾯train的代码⾥有具体的调⽤。 from__future__importprint_function importtensorflowastf defnormalize(inputs, epsilon=1e-8, scope=\"ln\", reuse=None): \'\'\'Applieslayernormalization. Args: inputs:Atensorwith2ormoredimensions,wherethefirstdimensionhas `batch_size`. epsilon:mallnumberforpreventingZeroDivisionError. scope:Optionalscopefor`variable_scope`. reuse:Boolean,whethertoreusetheweightsofapreviouslayer bythesamename. Returns: Atensorwiththesameshapeanddatadtypeas`inputs`. \'\'\' le_scope(scope,reuse=reuse): inputs_shape=_shape() params_shape=inputs_shape[-1:] mean,variance=s(inputs,[-1],keep_dims=True) beta=le((params_shape)) gamma=le((params_shape)) normalized=(inputs-mean)/((variance+epsilon)**(.5)) outputs=gamma*normalized+beta returnoutputs normalize函数⼀共有四个参数,输⼊数据inputs,epsilon是⼀个很⼩的数值防⽌加上它以防⽌数值计算中的⼀些错误出现。scope定义 tensorflow中的variable_scope,它和另⼀个参数reuse共同作为参数传给le_scope。最后normalizer函数返回normalize之后 的输出outputs。具体的计算函数体写得⽐较详细,就先不多说了。 下⾯是embedding函数的定义。 defembedding(inputs, vocab_size, num_units, zero_pad=True, scale=True, scope=\"embedding\", reuse=None): \'\'\'Embedsagiventensor. Args: inputs:A`Tensor`withtype`int32`or`int64`containingtheids tobelookedupin`lookuptable`. vocab_size:larysize. num_units:ofembeddinghiddenunits. zero_pad:,allthevaluesofthefistrow(id0) shouldbeconstantzeros. scale:putsismultipliedbysqrtnum_units. scope:Optionalscopefor`variable_scope`. reuse:Boolean,whethertoreusetheweightsofapreviouslayer bythesamename. Returns: A`Tensor`withonemorerankthaninputs\'tdimensionality shouldbe`num_units`. Forexample, ``` importtensorflowastf inputs=_int32(e((2*3),(2,3))) outputs=embedding(inputs,6,2,zero_pad=True) n()assess: (_variables_initializer()) (outputs) >> [[[0.0.] [0.097541460.67385566] [0.37864095-0.35689294]] [[-1.01329422-1.09939694] [0.75213420.38203377] [-0.04973143-0.06210355]]] ``` ``` importtensorflowastf inputs=_int32(e((2*3),(2,3))) outputs=embedding(inputs,6,2,zero_pad=False) n()assess: (_variables_initializer()) (outputs) >> [[[-0.19172323-0.39159766] [-0.43212751-0.66207761] [1.03452027-0.26704335]] [[-0.11634696-0.35983452] [0.502081330.53509563] [1.22204471-0.96587461]]] ``` \'\'\' le_scope(scope,reuse=reuse): lookup_table=_variable(\'lookup_table\', dtype=32, shape=[vocab_size,num_units], initializer=_initializer()) ifzero_pad: lookup_table=(((shape=[1,num_units]), lookup_table[1:,:]),0) outputs=ing_lookup(lookup_table,inputs) ifscale: outputs=outputs*(num_units**0.5) returnoutputs 总体⽽⾔这个⽂件中的函数注释写的都还是⽐较详细的。该函数输⼊就是要被lookedup的整型张量,⽽该函数返回的张量⽐输⼊张量多⼀ 个rank,最后⼀维⼤⼩为num_units,即lookup的embedding。同时该函数还提供了⼀个可选项即zero_pad,默认为True,表⽰初始化 lookup_table时把id=0的那⼀⾏(第⼀⾏)初始化为全0的结果。scale参数对outputs根据num_units的⼤⼩进⾏了scale,当scale为 True时执⾏scale,默认为True。其他参数函数体都有详细的注释就不多分析了。 好了,下⾯这个函数可以说是整个模型⾥⾯最重要的⼀个函数。它实现了模型所提出的attention的具体细节,模型⽹络结构的核⼼部分就 是依赖于它,所以以下进⾏细致的分析。 以下我之前对论⽂attention结构的⼀⼩段分析。 作者发现先对queries,keys以及values进⾏hhh次不同的线性映射效果特别好。学习到的线性映射分别映射到dkdkd_k,dkdkd_k以及 dvdvd_v维。分别对每⼀个映射之后的得到的queries,keys以及values进⾏attention函数的并⾏操作,⽣成dvdvd_v维的output值。 具体操作细节如以下公式。 这⾥映射的参数矩阵,,,。 本⽂中对并⾏的attention层(或者成为头)使⽤了的设定。其中每层都设置为.由于每个头都减少了维 度,所以总的计算代价和在所有维度下单头的attention是差不多的。 下⾯先不把函数代码直接粘上来,⼀次粘⼀部分慢慢分析。 defmultihead_attention(queries, keys, num_units=None, num_heads=8, dropout_rate=0, is_training=True, causality=False, scope=\"multihead_attention\", reuse=None): \'\'\'Appliesmultiheadattention. Args: queries:A3dtensorwithshapeof[N,T_q,C_q]. keys:A3dtensorwithshapeof[N,T_k,C_k]. num_units:ionsize. dropout_rate:Afloatingpointnumber. is_training:llerofmechanismfordropout. causality:,unitsthatreferencethefuturearemasked. num_heads:ofheads. scope:Optionalscopefor`variable_scope`. reuse:Boolean,whethertoreusetheweightsofapreviouslayer bythesamename. Returns A3dtensorwithshapeof(N,T_q,C) \'\'\' 以上这部分是函数的参数和返回值的介绍。attention的具体定义论⽂已经介绍这⾥不再细讲,其中主要包括三个部分,queries,keys和 values,在本实现中,keys和values是⼀样的,所以函数参数值写了⼀个。其中num_units为attention的⼤⼩,不设置则默认和queries 最后⼀维的units⼤⼩即C_q⼀样。num_heads即论⽂中所提到的head的个数,默认为论⽂中设定的值即8。is_training参数作为是否要 dropout的参考。causality参数默认为False,如果为True的话表明进⾏attention的时候未来的units都被屏蔽了,论⽂中也对此有专门的介 绍。 该函数返回的是attention之后的张量,shape为(N,T_q,C). 函数头之后是函数体,le_scope(scope,reuse=reuse):不多说。 下⾯再来⼀段代码进⾏分析。 MultiHead(Q,K,V)=Concat(head,...,head)1h where:head=iAttention(QW,KW,VW)i Q i K i V W∈i QRd∗dmodelkW∈i KRd∗dmodelkW∈i VRd∗dmodel v h=8d=kd=vd/h=model64 #Setthefallbackoptionfornum_units ifnum_unitsisNone: num_units=_shape().as_list[-1] #Linearprojections Q=(queries,num_units,activation=)#(N,T_q,C) K=(keys,num_units,activation=)#(N,T_k,C) V=(keys,num_units,activation=)#(N,T_k,C) 这⾥就像之前对多头attention的介绍⼀样,⾸先对queries,keys以及values进⾏全连接的变换,变换后的shape分别为(N,T_q,C),(N, T_k,C)以及(N,T_k,C)。 之后将变换后的Q,K,V从最后⼀维分开分成num_heads份(默认为8),代码如下: #Splitandconcat Q_=((Q,num_heads,axis=2),axis=0)#(h*N,T_q,C/h) K_=((K,num_heads,axis=2),axis=0)#(h*N,T_k,C/h) V_=((V,num_heads,axis=2),axis=0)#(h*N,T_k,C/h) 以上代码将之前变换后的Q,K,V分为num_heads份,并将这些分开的张量重新在第⼀个维度拼接起来进⾏后续的运算。形成了新的 Q_,K_,V_,其shape为h*N,T_q,C/h),(h*N,T_k,C/h)以及(h*N,T_k,C/h). 继续看代码: #Multiplication outputs=(Q_,ose(K_,[0,2,1]))#(h*N,T_q,T_k) #Scale outputs=outputs/(K_.get_shape().as_list()[-1]**0.5) 将张量K_transopose之后和Q_进⾏了矩阵乘法的操作,其实就是attention计算时算attentionscore的⼀个⽅法,即向量的点乘。这⾥是 把所有向量⼀起操作。操作的输出为outputs,然后再对该输出进⾏论⽂中所提到的scale操作,outputs的shape为[h*N,T_q,T_k]. 以下的代码是对key的屏蔽: #KeyMasking key_masks=((_sum(keys,axis=-1)))#(N,T_k) key_masks=(key_masks,[num_heads,1])#(h*N,T_k) key_masks=(_dims(key_masks,1),[1,(queries)[1],1])#(h*N,T_q,T_k) paddings=_like(outputs)*(-2**32+1) outputs=((key_masks,0),paddings,outputs)#(h*N,T_q,T_k) ⾸先说⼀下以上keymasking的代码事项做什么。它是想让那些key值的unit为0的key对应的attentionscore极⼩,这样在加权计算value 的时候相当于对结果不造成影响。 所以⾸先⽤⼀个reduce_sum(keys,axis=-1))将最后⼀个维度上的值加起来,keys的shape也从[N,T_k,C_k]变为[N,T_k]。然后再⽤abs 取绝对值,即其值只能为0(⼀开始的keys值第三个维度值全部为0,reduce_sum加起来之后为0),或正数(⼀开始的keys值第三个维 度值并⾮全为0,reduce_sum加起来之后为⾮零数取绝对值为正数)。然后⽤到了(x,name=None),该函数返回符号y=sign(x) =-1ifx0,sign会将原tensor对应的每个值变为-1,0,或者1。则经此操作,得到key_masks,有两个值,0或者 1。0代表原先的keys第三维度所有值都为0,反之则为1,我们要mask的就是这些为0的key。 接下来⽤到了⼀个函数,简单介绍以下:(input,multiples,name=None),通过简单的堆接⼀个给定的tensor构建⼀个新的 tensor,新的outputtensor的第i维有(i)*multiples[i]个元素。这⾥的(key_masks,[num_heads,1])就把原来的 shape为(N,T_k)的key_masks转化为shape为(hN,T_k)的key_masks。(扩充第⼀个维度的作⽤是要与之前的split操作及concat操作保 持⼀直,也就是对应多头的attention)。 然后⼜是⼀个操作,由于每个queries都要对应这些keys,⽽mask的key对每个queries都是mask的。⽽之前的key_masks只相当 于⼀份mask,所以扩充之前key_masks的维度,在中间加上⼀个维度⼤⼩为queries的序列长度。然后利⽤tile函数复制相同的mask值即 可。 然后定义⼀个和outputs同shape的paddings,该tensor每个值都设定的极⼩。⽤where函数⽐较,当对应位置的key_masks值为0也就 是需要mask时,outputs的该值(attentionscore)设置为极⼩的值(利⽤paddings实现),否则保留原来的outputs值。 经过以上keymask操作之后outputs的shape仍为(hN,T_q,T_k),只是对应mask了的key的score变为很⼩的值。 继续看代码: #Causality=Futureblinding ifcausality: diag_vals=_like(outputs[0,:,:])#(T_q,T_k) tril=OperatorTriL(diag_vals).to_dense()#(T_q,T_k) masks=(_dims(tril,0),[(outputs)[0],1,1])#(h*N,T_q,T_k) paddings=_like(masks)*(-2**32+1) outputs=((masks,0),paddings,outputs)#(h*N,T_q,T_k) 之前介绍参数的时候说了,causality参数告知我们是否屏蔽未来序列的信息(解码器selfattention的时候不能看到⾃⼰之后的那些信 息),这⾥即causality为True时的屏蔽操作。 该部分实现还是⽐较巧妙的,利⽤了⼀个三⾓阵的构思来实现。下⾯详细介绍。 ⾸先定义⼀个和outputs后两维的shape相同shape(T_q,T_k)的⼀个张量(矩阵)。 然后将该矩阵转为三⾓阵tril。三⾓阵中,对于每⼀个T_q,凡是那些⼤于它⾓标的T_k值全都为0,这样作为mask就可以让query只取它之前 的key(selfattention中query即key)。由于该规律适⽤于所有query,接下来仍⽤tile扩展堆叠其第⼀个维度,构成masks,shape为 (h*N,T_q,T_k). 之后两⾏代码进⾏paddings,和之前keymask的过程⼀样就不多说了。 以上操作就可以当不需要来⾃未来的key值时将未来位置的key的score设置为极⼩。 之后⼀⾏代码outputs=x(outputs)#(h*N,T_q,T_k)将attentionscore了利⽤softmax转化为加起来为1的权值,很简单。 接下来是QueryMasking: query_masks=((_sum(queries,axis=-1)))#(N,T_q) query_masks=(query_masks,[num_heads,1])#(h*N,T_q) query_masks=(_dims(query_masks,-1),[1,1,(keys)[1]])#(h*N,T_q,T_k) outputs*=query_masks#broadcasting.(N,T_q,C) 所谓要被mask的内容,就是本⾝不携带信息或者暂时禁⽌利⽤其信息的内容。这⾥querymask也是要将那些初始值为0的queryies(⽐如 ⼀开始句⼦被PAD填充的那些位置作为query)mask住。代码前三⾏和keymask的⽅式⼤同⼩异,只是扩展维度等是在最后⼀个维度展 开的。操作之后形成的query_masks的shape为[hN,T_q,T_k]。 第四⾏代码直接⽤outputs的值和query_masks相乘。这⾥的outputs是之前已经softmax之后的权值。所以此步之后,需要mask的权值 会乘以0,不需要mask的乘以之前取的正数的sign为1所以权值不变。实现了query_masks的⽬的。 这⾥源代码中的注释应该写错了,outputs的shape不应该是(N,T_q,C)⽽应该和query_masks的shape⼀样,为(hN,T_q,T_k)。 剩下的代码就简单多了: #Dropouts outputs=t(outputs,rate=dropout_rate,training=t_to_tensor(is_training)) #Weightedsum outputs=(outputs,V_)#(h*N,T_q,C/h) #Restoreshape outputs=((outputs,num_heads,axis=0),axis=2)#(N,T_q,C) #Residualconnection outputs+=queries #Normalize outputs=normalize(outputs)#(N,T_q,C) ⾸先对各种mask之后计算的权值outputs进⾏dropout,然后⽤该outputs和V_加权和计算出多个头attention的结果,这⾥直接⽤了 matmul矩阵乘法计算。outputs的shape为(h*N,T_q,T_k),V_的shape为(h*N,T_k,C/h),则相乘之后得到的加权和的outputsshape 为(h*N,T_q,C/h)。 由于这是多头attention的结果在第⼀个维度堆叠着,所以现在把他们split开重新concat到最后⼀个维度上就形成了最终的outputs,其 shape为(N,T_q,C)。 之后outputs加上⼀开始的queries,是⼀个residual的操作,然后⽤之前定义好的normalize函数将outputs处理。 returnoutputs 返回最终的outputs。 以上就是multihead_attention函数的全部分析。可以说,以上multihead_attention函数是论⽂的核⼼思想,也是该开源代码的核⼼。 论⽂中还提到了要把输出送⼊全连接的前馈⽹络,接下来是这部分代码。 deffeedforward(inputs, num_units=[2048,512], scope=\"multihead_attention\", reuse=None): \'\'\'Point-wisefeedforwardnet. Args: inputs:A3dtensorwithshapeof[N,T,C]. num_units:Alistoftwointegers. scope:Optionalscopefor`variable_scope`. reuse:Boolean,whethertoreusetheweightsofapreviouslayer bythesamename. Returns: A3dtensorwiththesameshapeanddtypeasinputs \'\'\' le_scope(scope,reuse=reuse): #Innerlayer params={\"inputs\":inputs,\"filters\":num_units[0],\"kernel_size\":1, \"activation\":,\"use_bias\":True} outputs=1d(**params) #Readoutlayer params={\"inputs\":outputs,\"filters\":num_units[1],\"kernel_size\":1, \"activation\":None,\"use_bias\":True} outputs=1d(**params) #Residualconnection outputs+=inputs #Normalize outputs=normalize(outputs) returnoutputs 其输⼊是⼀个shape为[N,T,C]的张量,num_units是隐藏节点的个数。 该部分操作利⽤⼀维卷积进⾏⽹络的设计,当时刚⼀看到代码我还懵了,不过这样确实可以做到。两层卷积之间加了relu⾮线性操作。之后 是residual操作加上inputs残差,然后是normalize。我好奇的是为什么作者不直接⽤直接进⾏全连接。 最后,对label进⾏了平滑操作: deflabel_smoothing(inputs,epsilon=0.1): \'\'\'ps:///abs/1512.00567. Args: inputs:A3dtensorwithshapeof[N,T,V],whereVisthenumberofvocabulary. epsilon:Smoothingrate. Forexample, ``` importtensorflowastf inputs=t_to_tensor([[[0,0,1], [0,1,0], [1,0,0]], [[1,0,0], [1,0,0], [0,1,0]]],32) outputs=label_smoothing(inputs) n()assess: print(([outputs])) >> [array([[[0.03333334,0.03333334,0.93333334], [0.03333334,0.93333334,0.03333334], [0.93333334,0.03333334,0.03333334]], [[0.93333334,0.03333334,0.03333334], [0.93333334,0.03333334,0.03333334], [0.03333334,0.93333334,0.03333334]]],dtype=float32)] ``` \'\'\' K=_shape().as_list()[-1]#numberofchannels return((1-epsilon)*inputs)+(epsilon/K) 这部分注释很详细,就不多做介绍了。可以看出把之前的one_hot中的0改成了⼀个很⼩的数,1改成了⼀个⽐较接近于1的数。 就到此为⽌了,该部分是核⼼内容,虽然⽐较复杂,但是⼀⾏⼀⾏看还是可以理解的。接下来是⽂件,看看⽹络模型 是如何连接并train起来的。 ⾸先是模型导⼊的包: from__future__importprint_function importtensorflowastf fromhyperparamsimportHyperparamsashp fromdata_loadimportget_batch_data,load_de_vocab,load_en_vocab frommodulesimport* importos,codecs fromtqdmimporttqdm 这⾥分别导⼊了之前写好的⼏个⽂件,同时还导⼊了⼀个叫做tqdm的模块,⽤于编写训练进度的进度条。 classGraph(): def__init__(self,is_training=True): =() _default(): ifis_training: self.x,self.y,_batch=get_batch_data()#(N,T) else:#inference self.x=older(32,shape=(None,)) self.y=older(32,shape=(None,)) #definedecoderinputs r_inputs=((_like(self.y[:,:1])*2,self.y[:,:-1]),-1)#2: #Loadvocabulary de2idx,idx2de=load_de_vocab() en2idx,idx2en=load_en_vocab() 以上代码创建了⼀个Graph类,⽅便tensorflow中图的创建。之后所有图中定义的节点和操作都是以这个图为默认的图的。 ⾸先加载训练数据或测试数据。如果是训练过程,则由之前写好的get_batch_data()得到训练数据以及batch的数量。如果是推断的过程, 则将数据定义为placeholder先放着。 数据self.x和self.y的shape都为[N,T]. 然后⽤self.y来初始化解码器的输⼊。decoder_inputs和self.y相⽐,去掉了最后⼀个句⼦结束符,⽽在每句话最前⾯加了⼀个初始化为2 的id,即,代表开始。shape和self.y⼀样为[N,T]。 利⽤之前⽂件中写好的⽅法加载德语和英语双语语⾔的id/word字典。 继续看代码: #Encoder le_scope(\"encoder\"): ##Embedding =embedding(self.x, vocab_size=len(de2idx), num_units=_units, scale=True, scope=\"enc_embed\") ##PositionalEncoding +=embedding((_dims(((self.x)[1]),0),[(self.x)[0],1]), vocab_size=, num_units=_units, zero_pad=False, scale=False, scope=\"enc_pe\") ##Dropout =t(, rate=t_rate, training=t_to_tensor(is_training)) ##Blocks foriinrange(_blocks): le_scope(\"num_blocks_{}\".format(i)): ###MultiheadAttention =multihead_attention(queries=, keys=, num_units=_units, num_heads=_heads, dropout_rate=t_rate, is_training=is_training, causality=False) ###FeedForward =feedforward(,num_units=[4*_units,_units]) 这段代码看起来长其实没有什么,主要是定义了encoder的结构,其定义过程中⽤的⽅法⼤都是在之前中介绍过的。 ⾸先利⽤定义好的embedding函数对self.x这⼀输⼊进⾏embedding操作。embedding之后的的shape为 [N,T,_units]。这⼀步只是对词的embedding。同时为了保留句⼦的前后时序信息,需要有⼀个对位置的embedding,这部分⽤了 简单的positionalembedding,和论⽂中的描述有⼀些不同,不过论⽂作者说两者都可以。 positionalembedding也是⽤之前的embedding函数,只不过embedding的输⼊的第⼆各维度的值不是词的id,⽽是变成了该词的位置 id,⼀共只有maxlen种这样的id,位置的id利⽤了实现,最后扩展到了batch中的所有句⼦,因为每个句⼦中词的位置id都是⼀样 的。将wordembedding和positionalembedding加起来,构成了最终的编码器embedding输⼊,shape仍为 [N,T,_units]。 得到embedding输⼊之后先进⾏dropout操作,该步操作只在寻来拿的时候执⾏。 最后就是将输⼊送到block单元中进⾏操作。按照论⽂中描述的,默认为6个这样的block结构。所以代码循环6次。其中每个block都调⽤了 依次multihead_attention以及feedforward函数.在编码器中,multihead_attention的queries和keys都是,所以这⼀部分是self attention。attention之后的结果送到feedforward中进⾏转换,形成该blocks的输出赋给。 接下来是decoder模块。 #Decoder le_scope(\"decoder\"): ##Embedding =embedding(r_inputs, vocab_size=len(en2idx), num_units=_units, scale=True, scope=\"dec_embed\") ##PositionalEncoding +=embedding((_dims(((r_inputs)[1]),0),[(r_inputs)[0],1]), vocab_size=, num_units=_units, zero_pad=False, scale=False, scope=\"dec_pe\") ##Dropout =t(, rate=t_rate, training=t_to_tensor(is_training)) ##Blocks foriinrange(_blocks): le_scope(\"num_blocks_{}\".format(i)): ##MultiheadAttention(self-attention) =multihead_attention(queries=, keys=, num_units=_units, num_heads=_heads, dropout_rate=t_rate, is_training=is_training, causality=True, scope=\"self_attention\") ##MultiheadAttention(vanillaattention) =multihead_attention(queries=, keys=, num_units=_units, num_heads=_heads, dropout_rate=t_rate, is_training=is_training, causality=False, scope=\"vanilla_attention\") ##FeedForward =feedforward(,num_units=[4*_units,_units]) 该部分是解码模块。类似于编码器,编码器也是wordembedding和positionalembedding加上dropout。得到的结果为 ,shape为[N,T,_units]。这部分和编码器⼀样就不多说。 接下来也是blocks的模块。不同于编码器只有⼀个selfattention结构,这⾥有两个attention结果哦偶。第⼀个是⼀个selfattention,与 编码器中selfattention不同的是这⾥的attention不能利⽤之后queries的信息,所以要设定multihead_attention的causality参数为 True,以屏蔽未来的信息。 解码器的selfattention之后跟了⼀个和编码器输出作为keys的attention,从⽽将编码器和解码器联系起来。该attention中的causality设 置为False,因为解码器中的信息都可以被⽤到。 接着是⼀个feedforward层,和编码器中的⼀样,这种blocks同样有六层。 最终的解码器输出为,shape为[N,T,_units]。 继续看剩下的代码: #Finallinearprojection =(,len(en2idx)) =_int32(_max(,dimension=-1)) et=_float(_equal(self.y,0)) =_sum(_float((,self.y))*et)/(_sum(et)) (\'acc\',) ⾸先通过全了链接将解码器的输出转化为shape为[N,T,len(en2idx)]的tensor即。然后取logits最后⼀维中最⼤的值的下标(预 测的值的下标)转化为int32类型的tensor,即,其shape为[N,T]。同时把label(即self.y)中所有id不为0(即是真实的 word,不是pad)的位置的值⽤float型的1.0代替作为et,其shape为[N,T]。 然后定义⼀个描述精确度的张量。在所有是target的位置中,当和self.y中对应位置值相等时转为float1.0,否则为0。 把这些相等的数加起来看⼀共占所有target的⽐例即精确度。然后将加⼊summary可以监督训练的过程。 继续看代码: ifis_training: #Loss self.y_smoothed=label_smoothing(_hot(self.y,depth=len(en2idx))) =x_cross_entropy_with_logits(logits=,labels=self.y_smoothed) _loss=_sum(*et)/(_sum(et)) #TrainingScheme _step=le(0,name=\'global_step\',trainable=False) zer=timizer(learning_rate=,beta1=0.9,beta2=0.98,epsilon=1e-8) _op=ze(_loss,global_step=_step) #Summary (\'mean_loss\',_loss) =_all() 以上代码是只有在训练时才需要的。定义了训练过程中需要⽤到的⼀些参数。 ⾸先对label进⾏平滑,将self.y转为one_hot之后⽤module中定义的label_smoothing函数进⾏平滑操作。之后,将平滑操作之后的值作 为labels和之前的logits联合起来⽤x_cross_entropy_with_logits函数计算交叉熵作为训练的loss。此时loss的shape为 [N,T]。⽽这其中⼜那些pad部分的⽆效词的loss,所以*et去掉⽆效的loss就是真正需要的loss。将这些loss加起来算 出平均值极为最后的_loss。 接着定义global_step,同时选取优化算法。并定义train_op。将mean_loss也加⼊summary便于追踪。 train⽂件的最后⼀部分代码即train模型以及保存的过程: if__name__==\'__main__\': #Loadvocabulary de2idx,idx2de=load_de_vocab() en2idx,idx2en=load_en_vocab() #Constructgraph g=Graph(\"train\");print(\"Graphloaded\") #Startsession sv=isor(graph=, logdir=, save_model_secs=0) d_session()assess: forepochinrange(1,_epochs+1): _stop():break forstepintqdm(range(_batch),total=_batch,ncols=70,leave=False,unit=\'b\'): (_op) gs=(_step) (sess,+\'/model_epoch_%02d_gs_%d\'%(epoch,gs)) print(\"Done\") ⾸先加载双语字典。然后构建刚才定义⽤的图对象。定义⼀个supervisorsv⽤以监督长时间的训练。所以训练以及保存都⽤sv⾃带的 session。训练epoch次,每个epoch内执⾏num_batch次train_op操作,并保存训练的结果。该部分主要⽤了tqdm来显⽰进度条。关于 tqdm模块的⽤法可以参考:,⽤起来也是⽐较简便的。 以上就是⽂件⾥⾯所有代码的分析。 还剩下最后⼀个⽂件,即评估⽂件,,⽤来评估模型的效果。 ⾸先载⼊要⽤的模块: from__future__importprint_function importcodecs importos importtensorflowastf importnumpyasnp fromhyperparamsimportHyperparamsashp fromdata_loadimportload_test_data,load_de_vocab,load_en_vocab fromtrainimportGraph _scoreimportcorpus_bleu 这⾥载⼊了之前⼏个必须的包以及之前定义的模块。最后⼀个模块是nltk⾥⾯⽅便计算翻译效果bleuscore的模块。具体⽤到时再细说。 接下来加载要测试的数据。 defeval(): #Loadgraph g=Graph(is_training=False) print(\"Graphloaded\") #Loaddata X,Sources,Targets=load_test_data() de2idx,idx2de=load_de_vocab() en2idx,idx2en=load_en_vocab() ⾸先加载之前定义好的图,把is_training置为False。然后利⽤load_test_data加载测试数据。并且加载双语word/id字典。 接着载⼊之前的模型: #Startsession _default(): sv=isor() d_session(config=Proto(allow_soft_placement=True))assess: ##Restoreparameters e(sess,_checkpoint()) print(\"Restored!\") ##Getmodelname mname=open(+\'/checkpoint\',\'r\').read().split(\'\"\')[1]#modelname 利⽤载⼊的模型对测试数据进⾏翻译: ##Inference (\'results\'):(\'results\') (\"results/\"+mname,\"w\",\"utf-8\")asfout: list_of_refs,hypotheses=[],[] foriinrange(len(X)//_size): ###Getmini-batches x=X[i*_size:(i+1)*_size] sources=Sources[i*_size:(i+1)*_size] targets=Targets[i*_size:(i+1)*_size] ###Autoregressiveinference preds=((_size,),32) forjinrange(): _preds=(,{g.x:x,g.y:preds}) preds[:,j]=_preds[:,j] 数据⼀共⼜多少个batch就循环多少次。针对每⼀个batch的循环,取⼀个mini-batch的数据x。同时将这个batch的双语原始句⼦也⽤ sources和targets保存起来。然后尽⼼⼴泛以。⾸先初始化翻译结果为int32类型的⼀个张量,初始值为0,shape为[_size, ]。然后针对这个batch的句⼦从第⼀个词开始,每个词每个词地预测。这样,后⼀个词预测的时候就可以利⽤前⾯的信息来解 码。所以⼀共循环次,每次循环⽤之前的翻译作为解码器的输⼊翻译的⼀个词。注意:并不是⼀次直接翻译完⼀个句⼦。 循环结束后,这个batch的句⼦的翻译保存在preds中。 翻译完成之后将翻译结果写⼊到⽂件中: ###Writetofile forsource,target,predinzip(sources,targets,preds):#sentence-wise got=\"\".join(idx2en[idx]foridxinpred).split(\"\")[0].strip() (\"-source:\"+source+\"n\") (\"-expected:\"+target+\"n\") (\"-got:\"+got+\"nn\") () #bleuscore ref=() hypothesis=() iflen(ref)>3andlen(hypothesis)>3: list_of_([ref]) (hypothesis) preds的结果仍然是id形式的,所以写⼊⽂件的时候要转化为word。 对于sources,targets,preds中的每个句⼦同时进⾏以下操作: 将pred(pred为preds中的⼀个句⼦)的每个id转化为其对应的英⽂单词,然后将这些单词字符串⽤⼀个空格字符串链接起来(join函数的 ⽤法)。同时去掉句尾结束符。这样就得到了翻译的由词组成的句⼦。 分别将源句⼦,期望翻译的结果以及实际翻译的结果写⼊⽂件。 将期望翻译的句⼦split成列表作为ref,同时模型翻译的句⼦split乘列表作为hypothesis。 最后就是计算bleuscore并写⼊到⽂件: ##Calculatebleuscore score=corpus_bleu(list_of_refs,hypotheses) (\"BleuScore=\"+str(100*score)) 将⼆者长度都⼤于3的句⼦加⼊到总的列表中作为计算bleu的参数。由此就得到了bleuscore,可以⽤来评估模型。将其写⼊⽂件末尾。 最后是执⾏评估函数: if__name__==\'__main__\': eval() print(\"Done\") ⾄此,整个transformer的代码都分析完了。