深度解析:Transformer模型实现——以tensor2tensor为例

背景

最近在做机器翻译的优化,接触到的是谷歌在18年发布的transformer模型,在经历过一个星期后的算法原理和源码阅读后,基本上对整个模型有了相对透彻的理解,下面对整个流程进行复盘避免以后自己忘记,后面也会对相关优化进行简单介绍。

预处理

在对翻译处理的过程,首先需要对一句话进行分词,比如“我是一个好学生”,分词出来后可能就是“我”,“是”,“一”,“ 个”,“好”,“学”,“生”,这里我们只要知道分词后每一个词是一个token,而每一个token可以由一个整数来表示就可以了,比如以上可以由7个数字组成,假如是[23, 432, 561, 12, 78, 29, 73]。我们知道大部分翻译模型是时序的,也就是说“i am a student”这四个单词每次只能吐出一个词,所以一共要吐几次,这个就需要提前约定好,在这里是将原文句子长度的3倍和一个固定参数(比如100)作比较,取最小值作为吐词个数的上限,在这里是min(7*3, 100)=21,不过一旦在吐词过程中吐出单词end的话也会停止翻译,即使当下还没有达到21个词。

编码器

目前输入为[23, 432, 561, 12, 78, 29, 73],在这里编码器的输入为固定长度,比如是10, 所以要进行补零操作[23, 432, 561, 12, 78, 29, 73,0,0,0],接下来在处理深度模型时我们知道一般是要进行one-hot编码的,如果整数范围为(0~32768),那么编码后就变成一个(1, 10,32768)的二维数组(为了好说明,我们batch一直设置为1,[[001…00], [000…10], …]),为了简化降维,或者是说选择一种更合适的编码方式,我们实际上使用的是embeding编码,原理比较简单,最通俗的理解可以是,我们可以根据23,432…这些数字去一个表中(这个表的维度假如是(32748,1024))查找自己对应的编码,目前输入input_tensor我们就处理好了,input_tensor.shape=(1,7,1024)。

数据处理好了以后, 接下是进入核心的编码器,我假如编码器只有一层,该层中包含,self-attention和ffn两个部分。首先先介绍self-attention部分。

self-attention:

图一 self_atttention

可以看到首先是一个预处理,直接看预处理代码:

def layer_preprocess(layer_input):
  with tf.variable_scope("layer_preprocess"):
    with tf.variable_scope("layer_norm"):
      num_units = layer_input.shape[-1]
      scale = tf.get_variable(
        "layer_norm_scale", [num_units], initializer=tf.ones_initializer(), trainable=True)
      bias = tf.get_variable(
        "layer_norm_bias", [num_units], initializer=tf.zeros_initializer(), trainable=True)
      x = layer_input
      # return layer_process_module.opt_layer_preprocess_dthree(x,scale,bias)
      # return tf.user_ops.opt_layer_preprocess_dthree(x,scale,bias)
      epsilon, scale, bias = [tf.cast(t, x.dtype) for t in [tf.constant(1e-6), scale, bias]]
      mean = tf.reduce_mean(x, axis=[-1], keepdims=True)
      variance = tf.reduce_mean(tf.square(x - mean), axis=[-1], keepdims=True)
      norm_x = (x - mean) * tf.rsqrt(variance + epsilon)
      return norm_x * scale + bias

其实原理比较简单,假如数据为[[0.12,1.14,0.3,…0.31],[…], […] ,[…],[…],[…],[…]],这里只展示了10个词中第一个单词的1024维中的几个数,这里预处理就是对[0.12,1.14,0.3,…0.31]求方差mean和均值var,然后对[0.12,1.14,0.3,…0.31]进行一个norm操作,说不清楚这个意义是啥,尽然做了就做了,预处理后维度维(1,7,1024)。

mutihead-attention:

Q,K,V

首先还是看图,图中显示首先根据上一个流程得到的数据得到Q,K,V,这一部分原理就不再详述,网上有一篇文章写的很明白,这里我们只是理一下具体的具体的计算流程。这里我们设置的head为16,下面一直用这个参数。

def compute_qkv(q_a, m_a, kd, vd, ver):
  if m_a is None: m_a = q_a
  if ver == 1:
    q = compute_attention_component_v1(q_a, kd, "q")
    k = compute_attention_component_v1(m_a, kd, "k")
    v = compute_attention_component_v1(m_a, vd, "v")
  else:
    assert kd == vd
    q, k, v = compute_attention_component_v2([q_a, m_a, m_a], kd, "qkv")
  return q, k, v

看代码,其中的m_a目前先不用管,设置为None,可以看看到qkv可以通过函数compute_attention_component_v1函数,这个函数的就是一个矩阵乘法,第一个参数为输入数据(1,7,1024),第二数据为希望得到的维度k,即(1,7,k),在这里我们假设维度没有被压缩,k依然是1024,那么每一个qkv的维度都是(1,7,1024),接下来我们需要进行分头,那么qkv的维度就会变为(1,7,16,64),16是head,1024被分为16份后变成64。接下为了计算方便做了一个转置,qkv数据维度为(1,16,7,64)。

dot_product_attention

图中显示接下来需要将qkv做一次大整合,其中q在整合之前还乘了1/(根号64), 为什么除8也不清楚,感觉是为了压缩q值,这里的 dot_product_attention是根据谷歌一篇论文来的《Self-Attention with Relative Position Representations》,理论比较简单,计算流程分为几个小步骤:

  1. 计算q乘k,二者相乘得到一个x, x的维度(1,16,7,7),也就说每一单词的q与每一个单词的k乘法

2.计算position_k:

具体操作时,首先去确定一个最大位置相关度的数,这里假定为20,也就是说位置相差20以内的单词有关联,也就是说我们需要把一句话的所有单词相对位置钳位到【-20~20】之间,举个例子,如果max_position为5的话,那么第一个单词相对位置序列为[0,1,2,3,4,5,6],  可以钳位到[0,1,2,3,4,5,5]这个序列,那么第二个单词位置相对于其他位置为(-1, 0,1,2,3,4,5),具体如下图,然后去一个embeding_table(二维矩阵可以训练)中查表(和上面词embed一个意思,就是将这个7*7的矩阵中的数字换成64位的向量),其中table的维度是(2*max_position+1, 64)(因为有个0,所以要+1),得到的结果是position_k(每个单词距离其他位置的7*7*64矩阵),其中position.shape=(7,7,64)(意思是每个head的position都是一样的)。注意:在decode中和该处处理方式有差异,细节可以参考源码

3.计算position_k与q:

将q与position进行矩阵乘,q的维度为[1,16,7,64],position的维度为(7,7,64),那么得到结果是(7,1*16,7),接下来再reshape和transpose到y,y的shape为(1,16,7,7),这里的计算可以理解为:对每一个单词的每一个q做乘加,算出他的位置信息在v中的比重,以前的话只有x,现在加了一个y。

4,将x和y相加得到z,z的shape(1,16,7,7)

5.  对z先mask(为了让之前pading出来的单词的影响力将为0,这里可以不用在意),然后求softmax,结果为r,shape(1,16,7,7)

6   r 与v相乘得到m,维度为(1,16,7,64),r与position_v相乘得到n,n的维度(1,16,7,64),position_k和上面的position_k的获取完全一样。

  1. m与n相加得到result,shape为(1,16,7,64)

由于对nlp不是很了解,只能模糊感觉利用position_k,和position_v是为了将位置信息编码进去,反正人家谷歌说好那就好白,神经网络只要舍得加参数,无非就是一个大黑盒,参数越多,拟合的效果一般都会更好一些。

combine_head:

输出的结果当时是(1,7,1024),等于是折腾一圈又回到最初的原点,但其实已经物是人非。

output_transformer:

本来以为上面的记过已经是最终的结果,其实不然,在离开的时候又做了一次矩阵乘,得到(1,7,1024),整个mutihead_attention结束后是一个后处理。

layer_postprocess:

残差相加,和图像中的残差网络一样,没啥可说的,至此,整个self_attention全部结束,该部分也是transfomer最核心的计算,

可以看到这是一顿操作,参数不够一直加,哎,大力出奇迹,还是希望以后有大佬来解释解释。

fnn:

这个fnn比较简单,就是上一层结果做两次矩阵乘,可以看到fnn的输入还有一个pad_reduce,这个还是开始说过的pad时候的一些操作,具体比较简单,不在此详述,可以说目前,我们已经拿到了encode部分得到的编码信息。

decode:

上面的整个encode过程的结果为[1,7,1024](batch, length, hidden_num),简单的说就是将一句话进行了编码,考虑到解码应用到

多batch的场景下,所以这里我们假设是batch为3,也就是encode的输出结果是[3, 7, 1024].在细节讲述之前先对整体的思路进行概述。

我们先把解码器想像为一个黑盒,那么每次的输入就是维度为3的一维数组,

1.   初始的时候输入为【1,1,1】,1是开始的标志id,个数为batch_size

2.   然后进行embedding, embedding table和encode中的一样,都是将整型id转为1024的维度数组,【3,1024】

3.  经过一个decoder黑盒得到【3,1024】的结果

4.  将步骤3的结果进行一个矩阵乘法,【3,1024】* 【1024, 32768】得到【3, 32768】

  1. 步骤4的结果一共有3行,每行32768维,这里取32768维中值最大的位置信息作为其每一句话翻译结果比如【32, 456,1023】,并将结果保存下来存放在result表格中。

  2. 将【32, 456,1023】替换步骤1中的【1,1,1】重复上述操作直到达到翻译终止条件

7.这样第一句话(batch)的翻译结果就是(32,45, 67, …),第二句话就是(456,657, 45 …)

result–table

324567。。。
45665745。。。
102387923。。。

步骤3中的细节

黑盒解码器是由上图的小单元组成,如果解码器有六层,那么就有六个小单元串联起来,下面对每个小模块进行详细的分析。

layer_0

layer_0中主要是两块,self_attention和encdec_attention,

先看self_attention:


self_attention中基本是和encode里面的操作相同,layer_preposition和layer_postprocess两个部分不再详述,和encode部分完成相同

下面主要对multihead_attention进行阐述。

首先multihead_attention的输入为【3,1024】,那么计算q,k,v得到结果都是【3,1024】维,q我们先放着,现在我们需要把k和v

concat到cache_k, 和cache_v中。cache_K和cache_v是一个全局变量,一开始为空,且每一个单元都有自己cacha_k和cache_v,

有点细节需要主要的是,concat的形式应该如下,cache_k_0为第一次的结果,ABC代表batch为3的cache_k,cache_k_1为第二次cache应该有的形式,cache_V也是同理,cache_k和cache_v的维度为【time*batch_size, 1024】,在这里我们假设我们是做第二次,也就是上面概述的【32, 456,1023】替换步骤1中的【1,1,1】的过程那么cache_k的维度是【6, 1024】。

cache_k_0

A0[1.2,0.4,0.2…]
B0
C0

cache_k_1

A0[1.2,0.4,0.2…]
A1
B0
B1
C0
C1

我们知道q的形式如下, 维度为【3,1024】

q

A1[0.2,0.3,0.4…]
B1
C1

整体的tensorboard可以看上面encoder部分。

第一步计算cache_k*q,图中左边的流程就是该过程,得到的是(3,16*2)的矩阵,这里解释一下,3是batch, 16是head, 2

是概述中第二次循环。

第二步得到position_key,首先当下相对位置为0,毕竟自己相对于自己位置肯定是0,左边相对于自己的位置就是负数,因为

是第二次循环,所以左边只有一个结果,编码结果为【-1,0】,embedding 后为(2, 64)的矩阵。

第三步是计算position_key*q, 这里要注意的是,考虑到加速问题,我们不可能像原始计算方式那样扩充,翻转矩阵,我们需要

知道细节,通过指针调用减少不必要的内存拷贝的操作,图中的步骤三就是一个矩阵计算草图,position_key被重复应用的每一

个头和batch上,最终得到(3, 16*2)的矩阵。

第四步是将position_key*q+cache_k*q相加的到softmax,人后算一个softmax,这个操作就没画图了,理解很容易。这里可以直观感觉可以利用结合律将position_key+cache_k先相加,这个可以根据实际情况自主选择。

第五步是softmax*cache_v, 得到(3, 16*64)

第六步计算position_value,然后计算softmax*position_value+softmax*cache_v,得到(3,16*64)=(3,1024)



先看encdec_attention:


从tensorboard可以看到encdec的attention比较简单,首先multihead_attention的输入为【3,1024】,那么计算q得到结果是【3,1024】维,这里发现没有k,和v了,在dot_product_attention里面操作是,softmax(q*k)*V, 那么这里的k和v是什么,k和v就是将encode的输出(3,7,1024)的矩阵分别乘encdec_layer_0_weight_k和encdec_layer_0_weight_k得到k ,v    encdec_layer_0_weight_k的维度是(1024,1024),则k和v的维度始终都是(3, 7, 1024),布局和上面的self_attention中的cache_k和cahe_v一样。整个计算中也没有了relative position的操作,矩阵计算细节和上面softmax(q*k)*V一样,最终输出(3,1024)。

layer_0

这部分就不说,一个ffn,也就是里面做了两次矩阵计算,先从(3,1024)变换到(3,4096),再从(3,4096)变换到(3,1024)。

整个解码部分的单层layer就是这样,最后至于选择是greedy_search还是beam_search就不是解码器的工作了。

训练过程

其实transformer有一个很大的优势就是能够在训练中大幅度提速,可以并行化处理,那么会有一些问题,我们知道解码是自回归的,那么训练时候如何操作呢?

可以看到实际的对应标签是这样的,那么具体在解码部分的逻辑是什么样子的呢?

训练输入训练标签
SOS
i
love
youEOS

其实在训练过程中解码部分的attention引入了一个mask操作;比如现在batch=1,length=4,那么解码部分输入就是[4,1024], 在这里先不考虑batch,那么就可以得到q,k,v都是[4,1024], 那么q*k就会得到[4,4], 取第一行假如为

[0.1,0.2,0.3,0.4], 其实我们知道,推理过程中由于当下的输入只与之前的输入有关系(毕竟还没翻译出来,肯定与后面的没关系,在训练中只不过是假设的,其实不应该被看见,看不懂的好好理解自回归),所以只有0.1有效,

在看第二行[0.5,0.6,0.7,0.8]可以看到只与0.5和0.6有关,一次类推,后面只需要乘一个下三角矩阵就行了,后面计算softmax啥的就ok了。没亲自读过源码的可能这一块都不知道,所以理解模型还是越深入越好

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值