[深度学习] RNN对于变长序列的处理方法, 为什么RNN需要mask

2022-09-27 10:03:18 浏览数 (1)

大家好,又见面了,我是你们的朋友全栈君。

一 Padding

文本数据在处理的时候,由于各样本的长度并不一样,有的句子长有的句子短。抛开动态图、静态图模型的差异,由于需要进行矩阵运算,句长需要是等长的才可以,这就需要padding操作。padding一般是用最长的句子长度为最大长度,然后其他样本补0到最大长度,这样样本就是等长的了。

但是注意padding后的样本如果不作处理只用普通的循环神经网络来做的话其实是有影响的,因为即使输入的是0,做了embedding后也不是0,而且还有上一时刻隐藏层,所以输出不会是0。但是在实际使用中,padding的这种操作如果不做特殊处理,模型也是可以学到它是无用的padding。

但是这会有一个问题,什么问题呢?比如上图,句子“Yes”只有一个单词,但是padding了5的pad符号,这样会导致LSTM对它的表示通过了非常多无用的字符,这样得到的句子表示就会有误差,更直观的如下图:

结论:直接填充0,在数据运算上没有问题,但是从序列的整个含义来说,这是不合理的,所以一般情况下不能这么做。

RNN

在使用RNN based model处理序列的应用中,如果使用并行运算batch sample,我们几乎一定会遇到变长序列的问题。 通常解决变长的方法主要是将过长的序列截断,将过短序列用0补齐到一个固定长度(例如max_length)。 最后由n个sample组成的dataset能形成一个shape == (n, max_length)的矩阵。然后可以将这个矩阵传递到后续的模型中使用。 然而我们可以很明显,如果用0或者其他整数补齐,势必会影响到模型自身(莫名其妙被输入很多个0,显然是有问题的)。有什么方法能够做到“能够使用一个二维矩阵作为输入数据集,从而达到并行化的同时,还能让RNN模型自行决定真正输入其中的序列的长度。

Mask主要用于解决RNN中输入有多种长度的问题。要输入RNN中的是尺寸固定的张量,即批尺寸(batch size) * 序列长度(sequence length) * 嵌入大小(embedding size)。因为RNN在计算状态向量时不仅考虑当前,也考虑前一次的状态向量,如果为了维持真实长度,采用补0的方式,在进行状态向量计算的时候也会包含进用0补上的位置,而且这种方式无法进行彻底的屏蔽。由于喂到模型中是fixed-size tensor(如256*100*50, batch size * sequence length * embedding size), RNN需要用mask来maintain序列真实长度,从而在计算loss的时候去除掉padding的部分。

相比于补0,Mask会得到不同的状态向量。对于每一个用0初始化的的样本,我们建立一个Mask,并使其长度与数据集中最长的序列相同。然后样本中所有有数值的地方,我们用1把Mask中对应的位置填充起来。

举个例子,数据集中最长的序列长度为10,所以所有的Mask将以如下的方式初始化:

mask = [ 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.]

同时,我们还有一个如下的样本:

a = [ 2., 0. ,5. ,6. ]

现在我们用1将Mask中所有有数值的地方填充起来,因而得到以下的Mask:

mask_a = [ 1., 1., 1., 1., 0., 0., 0., 0., 0., 0.]

在这之后,我们将样本与mask输入RNN中,RNN将会把所有没有值的的地方加上0,所以a变成了:

a_hood = [ 2., 0. ,5. ,6., 0., 0., 0., 0., 0., 0.]

但是如果我们任由RNN用这种补0的方式,RNN会认为所有的序列长度都为10,并且在计算时用上所有的补上的0。

mask_a = [ 1., 1., 1., 1., 0., 0., 0., 0., 0., 0.]

而此时mask_a的作用就是让RNN跳过所有Mask为0的输入,复制cell中前一次的隐藏状态;对于Mask为1的输入RNN将按常规处理。

CNN

对于CNN来说,首先它的输入已经是固定尺寸,不需要Mask,其次就算用上Mask,结果和补0一样,所以采用补0这种方便的方法,而CNN是卷积操作,补0的位置对卷积结果没有影响,即补0和mask两种方式的结果是一样的,因此大家为了省事起见,就普遍在CNN使用补0的方法了。CNN的的输入本身就是fixed-size,所以不需要mask。

2. Keras

keras中对变长rnn的使用应该是最简单的了,只需设置embedding的参数mask_zero为true就可以了,注意设置为true后,需要后面的所有层都能够支持mask,比如LSTM之类的层。这里详细说一下当用预训练的词向量的时候,需要在训练好的词向量权重前补一个标签0的向量,并把embedding层设置为trainable=false

2.1 padding 0

代码语言:javascript复制
import keras as ks
import numpy as np
 
'''
#这是原始的输入数据,一共四组样本(四个句子),没组样本的时间跨度为3,即timesteps=3,每一个数字表示一个单词
#现在我想把每一个数字(即单词)转化成一个三维向量
#即
4->[#,#,#]
10->[#,#,#]
5->[#,#,#]
2->[#,#,#]
.
..依次下去
'''
input_array=np.array([[4,10,5],[2],[3,7,9],[2,5]])
X=ks.preprocessing.sequence.pad_sequences(input_array,maxlen=3,padding='post')
print(X)
 
model = ks.models.Sequential()
model.add(ks.layers.Embedding(100, 3, input_length=3,mask_zero=False))
rnn_layer=ks.layers.SimpleRNN(5,return_sequences=True)
model.add(rnn_layer)
 
 
model.compile('rmsprop', 'mse')
output_array = model.predict(X)
print(output_array)

此处在使用Embedding层的时候并没有传递关键字参数mask_zero=True,很多人觉得如果是传入了关键字参数mask_zero=True,那么0所对应的单词转化成向量之后应该全部是0,这是不正确的,这个地方不管有没有传入关键字参数mask_zero=True,输出的结果都是一样的,单词0都不会全部为0向量。

既然关键字参数mask_zero=True有没有都一样,那还需要他有什么用?这其实是后面的使用要用到的地方,因为Embedding层只能放在网络的第一层,用来对数据进行处理,当后面要跟循环层的时候,关键字参数mask_zero=True就发挥出作用了。

代码如下,只需要修改上面代码的一个地方,将false改为True即可:

代码语言:javascript复制
model.add(ks.layers.Embedding(100, 3, input_length=3,mask_zero=True))

总结:Embedding的关键字参数mask_zero=True不会改变Word2vector的结果,即不是讲所有补充的0全部变为0向量,这个很重要,关键字参数mask_zero=True的作用是决定了后面的“循环层”是否会将补充的0单词参与运算,如果设置为True,就是“覆盖掉0”的意思,自然就不参与运算,如果设置为False,就是不覆盖0,0也会参与运算的意思。

3. TensorFlow

TensorFlow中对变长rnn的支持靠tf.dynamic_rnn

代码语言:javascript复制
import tensorflow as tf
import numpy as np
import pprint
 
#样本数据为(samples,timesteps,features)的形式,其中samples=4,features=3,timesteps不固定,第二个样本只有一个步长,第四个样本只有2个步长
train_X = np.array([
[[0, 1, 2], [9, 8, 7],[3,6,8]], 
[[3, 4, 5]], 
[[6, 7, 8], [6, 5, 4],[1,7,4]], 
[[9, 0, 1], [3, 7, 4]]
])
#样本数据为(samples,timesteps,features)的形式,其中samples=4,timesteps=3,features=3,其中第二个、第四个样本所缺的样本自动补零
train_X = np.array([
[[0, 1, 2], [9, 8, 7],[3,6,8]], 
[[3, 4, 5], [0, 0, 0],[0,0,0]], 
[[6, 7, 8], [6, 5, 4],[1,7,4]], 
[[9, 0, 1], [3, 7, 4],[0,0,0]]
])
 
#  tensorflow处理变长时间序列的处理方式,首先每一个循环的cell里面有5个神经元
basic_cell=tf.nn.rnn_cell.BasicRNNCell(5)
 
#创建一个容纳训练数据的容器placeholder
X=tf.placeholder(tf.float32,shape=[None,3,3])
 
# 构建一个向量,这个向量专门用来存储每一个样本中的timesteps的数目,这个是核心所在
seq_length = tf.placeholder(tf.int32, [None])
 
#在使用dynamic_rnn的时候,传递关键字参数 sequence_length
outputs, states = tf.nn.dynamic_rnn(basic_cell, X, dtype=tf.float32,sequence_length=seq_length)
 
#实际上就是没一个样本的步长数所组成的一个数组
seq_length_batch = np.array([3, 1, 3, 2])
 
#在我们运行RNN的时候,需要将输入X和样本长度seq_length都传输进去,如下:
with tf.Session() as sess:
    sess.run(tf.global_variables_initializer())
    outputs_val, states_val = sess.run(
        [outputs, states], feed_dict={X:train_X,seq_length:seq_length_batch})
    
    pprint.pprint(outputs_val)
    pprint.pprint(states_val)
    print('==============================================')
    print(np.shape(outputs_val))
    print(np.shape(states_val))

由于TensorFlow是静态图模型,普通的rnn在计算图搭建好后,所有batch中的sequence长度都要是一样的,而dynamic_rnn只需要每个batch内部的sequence长度一样就可以了。sequence_length是每一句话未padding的实际长度,当rnn计算到padding的地方就不再更新权重,而是直接输出和上一个时刻相同的hidden state。

Tensorflow中对变长序列的输入是通过在dynamic_rnn()函数中的sequence_length参数来指定的,这个参数是一个一维数组,每一个数组的元素为所对应的那一组样本的timesteps数目,而填充的01根本就没有参与运算,只不过是一个“空壳子”,为了保持输入数据的维度完整性而存在的

4. Pytorch

像pytorch这种动态图模型就比较方便了,可以像写python代码一样任意的用while和for循环,每一次运行都会从新建立计算图。比如我们可以将batch设为1,每一步都只输入一句话,运行的时候从新设置网络中的rnn的sequence长度,这是可以的。当然,batch为1的话模型运行起来会比较慢,下面介绍另一种方法。

主要是用这三个函数的用法。

  1. torch.nn.utils.rnn.PackedSequence()
  2. torch.nn.utils.rnn.pack_padded_sequence()
  3. torch.nn.utils.rnn.pad_packed_sequence()

1、torch.nn.utils.rnn.PackedSequence()

NOTE: 这个类的实例不能手动创建。它们只能被 pack_padded_sequence() 实例化。

PackedSequence对象包括:

  • 一个data对象:一个torch.Variable(令牌的总数,每个令牌的维度),在这个简单的例子中有五个令牌序列(用整数表示):(18,1)
  • 一个batch_sizes对象:每个时间步长的令牌数列表,在这个例子中为:[6,5,2,4,1]

用pack_padded_sequence函数来构造这个对象非常的简单:

PackedSequence对象有一个很不错的特性,就是我们无需对序列解包(这一步操作非常慢)即可直接在PackedSequence数据变量上执行许多操作。特别是我们可以对令牌执行任何操作(即对令牌的顺序/上下文不敏感)。

2、torch.nn.utils.rnn.pack_padded_sequence()

这里的pack,理解成压紧比较好。 将一个填充过的变长序列压紧。(填充时候,会有冗余,所以压紧一下)

输入的形状可以是(T×B×* )。T是最长序列长度,B是batch size,*代表任意维度(可以是0)。如果batch_first=True的话,那么相应的 input size 就是 (B×T×*)。

Variable中保存的序列,应该按序列长度的长短排序,长的在前,短的在后(特别注意需要进行排序)。即input[:,0]代表的是最长的序列,input[:, B-1]保存的是最短的序列

NOTE: 只要是维度大于等于2的input都可以作为这个函数的参数。你可以用它来打包labels,然后用RNN的输出和打包后的labels来计算loss。通过PackedSequence对象的.data属性可以获取 Variable

参数说明:

  • input (Variable) – 变长序列 被填充后的 batch
  • lengths (list[int]) – Variable 中 每个序列的长度。(知道了每个序列的长度,才能知道每个序列处理到多长停止
  • batch_first (bool, optional) – 如果是True,input的形状应该是B*T*size。

返回值:

一个PackedSequence 对象。

具体代码如下:

代码语言:javascript复制
embed_input_x_packed = pack_padded_sequence(embed_input_x, sentence_lens, batch_first=True)
encoder_outputs_packed, (h_last, c_last) = self.lstm(embed_input_x_packed)

此时,返回的h_last和c_last就是剔除padding字符后的hidden state和cell state,都是Variable类型的。代表的意思如下(各个句子的表示,lstm只会作用到它实际长度的句子,而不是通过无用的padding字符,下图用红色的打钩来表示):

但是返回的output是PackedSequence类型的,可以使用:

代码语言:javascript复制
encoder_outputs, _ = pad_packed_sequence(encoder_outputs_packed, batch_first=True)

将encoderoutputs在转换为Variable类型,得到的_代表各个句子的长度。

3、torch.nn.utils.rnn.pad_packed_sequence()

填充packed_sequence

上面提到的函数的功能是将一个填充后的变长序列压紧。 这个操作和pack_padded_sequence()是相反的。把压紧的序列再填充回来。

返回的Varaible的值的sizeT×B×*, T 是最长序列的长度,B 是 batch_size,如果 batch_first=True,那么返回值是B×T×*

Batch中的元素将会以它们长度的逆序排列。

参数说明:

  • sequence (PackedSequence) – 将要被填充的 batch
  • batch_first (bool, optional) – 如果为True,返回的数据的格式为 B×T×*

返回值:

一个tuple,包含被填充后的序列,和batch中序列的长度列表。

4.1. Pytorch代码举例

将原始数据padding后,和sequence_length一起传入pack中。注意句子需要按实际长度从长到短排序,sequence_length也要相应对齐。

代码语言:javascript复制
import torch
import torch.nn as nn
from torch.nn import utils as nn_utils

batch_size = 2
max_length = 3
hidden_size = 2
n_layers = 1

print("1.--------------")
tensor_in = torch.FloatTensor([[1, 0, 0], [1, 2, 3]]).resize_(2, 3, 1)
seq_lengths = [1, 3]  # list of integers holding information about the batch size at each sequence step
print("seq_lengths:n", seq_lengths)
print("tensor_in:n", tensor_in)

print("2.--------------")
seq_lens = torch.IntTensor([1, 3])
_, idx_sort = torch.sort(seq_lens, dim=0, descending=True)
_, idx_unsort = torch.sort(idx_sort, dim=0)
order_seq_lengths = torch.index_select(seq_lens, dim=0, index=idx_sort)
order_tensor_in = torch.index_select(tensor_in, dim=0, index=idx_sort)
print("order_seq_lengths:n", order_seq_lengths)
print("order_tensor_in:n", order_tensor_in)

print("3.--------------")
x_packed = nn_utils.rnn.pack_padded_sequence(order_tensor_in, order_seq_lengths, batch_first=True)
rnn = nn.RNN(1, hidden_size, n_layers, batch_first=True)
h0 = torch.randn(n_layers, batch_size, hidden_size)
y_packed, h_n = rnn(x_packed, h0)
print('x_packed:n', x_packed)
print('y_packed:n', y_packed)

print("4.--------------")
y_sort, length = nn_utils.rnn.pad_packed_sequence(y_packed, batch_first=True)
print("sort unpacked output:n", y_sort)
print(y_sort.shape)

print("5.--------------")
# unsort output to original order
y = torch.index_select(y_sort, dim=0, index=idx_unsort)
print("org unpacked output:n", y)
print(y.shape)

print("6.--------------")
# unsort output to original order
last_h = torch.index_select(h_n[-1], dim=0, index=idx_unsort)
print("last hidden state:n", last_h)

4.2. Pytorch代码说明

pack函数会生成一个PackedSequence,包含data和batch_size。注意上面pack函数设置的batch_first为true,输入的维度是(batch_size, sequence_length, 1),也就是(2,3,1),我们将数据排列好后如下

[1,2,3]

[1,0,0]

data的数据形式是从第一列开始的一个一个的值,不包括0。batch_sizes是第一列有两个有效值,第二列有一个,第三列有一个。这样排列的原因是batch做矩阵运算的时候网络是先计算所有句子的第一位,然后第二位,第三位。

理解这里的PackedSequence是关键。

前面说到,RNN其实就是在循环地 forward。在上面这个例子中,它每次 forward 的数据是这样的:

第一个序列中,由于两个样本都有数据,所以可以看作是 batch_size=2 的输入,后面两个序列只有第一个样本有数据,所以可以看作是 batch_size=1 的输入。因此,我们其实可以把这三个序列的数据分解为三个 batch 样本,只不过 batch 的大小分别为 2,1,1。到这里你应该清楚PackedSequence里的databatch_size是什么东西了吧,其实就是把我们的输入数据重新整理打包成data,同时根据我们传入的 seqlist 计算batch_size,然后,RNN会根据batch_size从打包好的data里面取数据,然后一遍遍的执行 forward 函数。

理解这一步后,主要难点就解决了。

RNN的输出

从文档中可以看出,RNN输出两个东西:outputh_n。其中,h_n是跑完整个时间序列后 hidden state 的数值。但output又是什么呢?

之前不是说过原始的RNN只输出 hidden state 吗,为什么这里又会有一个output?其实,这个output并不是我们理解的网络最后的 output vector,而是每次 forward 后计算得到的 hidden state。毕竟h_n只保留了最后一步的 hidden state,但中间的 hidden state 也有可能会参与计算,所以 pytorch 把中间每一步输出的 hidden state 都放到output中,因此,你可以发现这个output的维度是(seq_len, batch, num_directions * hidden_size)

不过,如果你之前用pack_padded_sequence打包过数据,那么为了保证输入输出的一致性,pytorch 也会把output打包成一个PackedSequence对象,我们将上面例子的数据输入RNN ,看看输入与输出是什么样子的:

输出的PackedSequence中包含两部分,其中data才是我们要的output。但这个output的 shape 并不是(seq_len, batch, num_directions * hidden_size),因为 pytorch 已经把输入数据中那些填充的 0 去掉了,因此输出来的数据对应的是真实的序列长度。

我们要把它重新填充回一个方方正正的 tensor 才方便处理,这里会用到另一个相反的操作函数torch.nn.utils.pad_packed_sequence

现在,这个output的 shape 就是一个标准形式了。

输入输出顺序就一一对应了。

接下来可以从y中取出( hidden state) 或者一般直接使用y,接个全连接层fc之类的,得到真正意义上的 output vector。

取出last hidden state

最后要注意一点,因为pack_padded_sequence把输入数据按照 seq_lenseq_len 从大到小重新排序了,所以后面在计算 loss 的时候,要么把output的顺序重新调整回去,要么把 target 数据的顺序也按照新的 seq_lenseq_len 重新排序。代码中已经调整回原来的顺序了。当 target 是 label 时,调整起来还算方便,但如果 target 也是序列类型的数据,可能会多点体力活。

这样综上所述,RNN在处理类似变长的句子序列的时候,我们就可以配套使用torch.nn.utils.rnn.pack_padded_sequence()以及torch.nn.utils.rnn.pad_packed_sequence()来避免padding对句子表示的影响

参考:

  1. https://zhuanlan.zhihu.com/p/34418001
  2. https://blog.csdn.net/gt362ll/article/details/83653475
  3. https://blog.csdn.net/u010223750/article/details/71079036
  4. https://www.cnblogs.com/lindaxin/p/8052043.html
  5. https://www.cnblogs.com/jermmyhsu/p/10020308.html
  6. https://blog.csdn.net/qq_27825451/article/details/88991529

发布者:全栈程序员栈长,转载请注明出处:https://javaforall.cn/179077.html原文链接:https://javaforall.cn

0 人点赞