深度学习06 - LSTM网络-处理可变长序列输入问题

1、问题

RNN的输入是按照批次来进行输入的,默认是每一批次的数据是大小相同的,但是在某些时候,比如语音识别或nlp等领域输入的数据每一批次,每一组的特征数是不同的(例如每次说的话包含的单词个数是不同的),我们需要进行处理
在这里插入图片描述

2、解决问题

参考文档:序列长度不固定怎么办
需要使用到的函数:

torch.nn.utils.rnn.pad_sequence()
torch.nn.utils.rnn.pack_padded_sequence()
torch.nn.utils.rnn.pad_packed_sequence()

1、pad_sequence

我们构造如下矩阵,查看此函数的作用:

import torch
from torch import nn
import torch.nn.utils.rnn as rnn_utils

train_x = [torch.tensor([1, 1, 1, 1, 1, 1, 1]),
           torch.tensor([2, 2, 2, 2, 2, 2]),
           torch.tensor([3, 3, 3, 3, 3]),
           torch.tensor([4, 4, 4, 4]),
           torch.tensor([5, 5, 5]),
           torch.tensor([6, 6]),
           torch.tensor([7])]
x = rnn_utils.pad_sequence(train_x, batch_first=True)
print(x)

结果:

tensor([[1, 1, 1, 1, 1, 1, 1],
[2, 2, 2, 2, 2, 2, 0],
[3, 3, 3, 3, 3, 0, 0],
[4, 4, 4, 4, 0, 0, 0],
[5, 5, 5, 0, 0, 0, 0],
[6, 6, 0, 0, 0, 0, 0],
[7, 0, 0, 0, 0, 0, 0]])

我们看到这个函数的作用就是在每一批数据的后面进行补0,直到和最长序列长度相同,我们引入如下代码:

这样做的主要目的是为了让 DataLoader 可以返回 batch,因为 batch 是一个高维的 tensor,其中每个元素的数据必须长度相同。
为了证明DataLoader中一定是同一维度的数据:

import torch
from torch import nn
import torch.nn.utils.rnn as rnn_utils
from torch.utils.data import DataLoader
import torch.utils.data as data

train_x = [torch.tensor([1, 1, 1, 1, 1, 1, 1]),
           torch.tensor([2, 2, 2, 2, 2, 2]),
           torch.tensor([3, 3, 3, 3, 3]),
           torch.tensor([4, 4, 4, 4]),
           torch.tensor([5, 5, 5]),
           torch.tensor([6, 6]),
           torch.tensor([7])]

x = rnn_utils.pad_sequence(train_x, batch_first=True)

class MyData(data.Dataset):
    def __init__(self, data_seq):
        self.data_seq = data_seq

    def __len__(self):
        return len(self.data_seq)

    def __getitem__(self, idx):
        return self.data_seq[idx]


if __name__=='__main__':
    data = MyData(train_x)
    data_loader = DataLoader(data, batch_size=2, shuffle=True)
    batch_x = iter(data_loader).next()
    print(batch_x)
    print('END')

函数的具体做法就是将我们的数据打包放入DataLoader中,然后进行按批次迭代输出
运行报错:

RuntimeError: invalid argument 0: Sizes of tensors must match except in dimension
0.Got 3 and 7 in dimension 1 at

我们看到原因是:Sizes of tensors must match except in dimension
所以输入进DataLoader中的数据必须是整齐的

**具体要怎么做呢?

我们注意到DataLoader中有个参数 collate_fn,专门用来把 Dataset 类的返回值拼接成
tensor,我们不设置的时候,会调用 default 的函数,这次我们的训练数据长度不一,default 函数就 hold
不住了,因此我们要自定义一个 collate_fn,并在 DataLoader 中设置这个参数,再运行就不会报错了(注意代码中对 data
先按照长度降序排列了一下,后面会讲到原因)。

def collate_fn(data):
    data.sort(key=lambda x: len(x), reverse=True)
    data = rnn_utils.pad_sequence(data, batch_first=True, padding_value=0)
    return data

if __name__=='__main__':
    data = MyData(train_x)
    data_loader = DataLoader(data, batch_size=3, shuffle=True, 
                             collate_fn=collate_fn)
    batch_x = iter(data_loader).next()
    print(batch_x)
    print('END')

输出结果:

tensor([[4, 4, 4, 4],
[5, 5, 5, 0],
[6, 6, 0, 0]])
END

结果解释:
我们定义了collate_fn函数,因此DataLoader会将每一批次的数据都经历一次这个函数,然后再进行输出,经历这个函数就是将每一批次的数据补0补充到最大长度,因此出现了5后面补一个0,6后面补两个0的情况,引入4正好是4个数

2、pack_padded_sequence

我们通过 pad_sequence 得到了 padded_sequence,那么直接扔进 RNN 训练不就完了吗?为啥还要用 pack_padded_sequence?这个 pack 又是什么意思呢?
我们回忆一下 RNN 是如何训练的,首先考虑单个训练数据,也就是batch_size=1 的情况:每次网络吃进一个 time step 的数据+该数据对应的 hidden state,然后输出,再继续吃进去第二个 time step 的数据 + hidden state,再输出,以此类推;如果换成 mini-batch 的训练模式则是:网络每次吃进去一组同样 time step 的数据,也就是mini-batch 中所有 sequence 中相同下标的数据,加上它们对应的 hidden state,获得一个 mini-batch 的输出,然后再移到下一个 time step,再读入 mini-batch 中所有该 time step 的数据,再输出……
因此,以上面 pad_sequence的输出为例,数据将会按照如图所示的方式读取:
在这里插入图片描述
网络读取数据的顺序是:[1, 3, 6],[1, 3, 6],[1, 3, 0],[1, 3, 0],[1, 3, 0],[1, 0, 0],[1, 0, 0]。而该 mini-batch 中的 0 是没有意义的 padding,只是为了用来让它和最长的数据对齐而已,显然这种做法浪费了大量计算资源。因此,我们将用到 pack_padded_sequence 。即,不光要 padd,还要 pack。

pack_padded_sequence 有三个参数:input, lengths, batch_first 。input 是上一步加过 padding 的数据,lengths 是各个 sequence 的实际长度,batch_first是数据各个 dimension 按照 [batch_size, sequence_length, data_dim]顺序排列。

如batch_x为如下序列:

batch_x
Out[2]:
tensor([[1, 1, 1, 1, 1, 1, 1],
[3, 3, 3, 3, 3, 0, 0],
[6, 6, 0, 0, 0, 0, 0]])

我们设置的length应该是:lengths=[7, 5, 2]

因此,我们将shuffle设置为不打乱顺序,输出前三个批次,然后pack看一下效果:

if __name__=='__main__':
    data = MyData(train_x)
    data_loader = DataLoader(data, batch_size=3, shuffle=False,
                             collate_fn=collate_fn)
    batch_x = iter(data_loader).next()
    batch_x = rnn_utils.pack_padded_sequence(batch_x,[7,6,5],batch_first=True)
    print(batch_x)
    print('END')

输出:

PackedSequence(data=tensor([1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 1]), batch_sizes=tensor([3, 3, 3, 3, 3, 2, 1]), sorted_indices=None, unsorted_indices=None)
END

我们发现,它的输出有两部分,分别是 data 和 batch_sizes,第一部分为原来的数据按照 time step 重新排列,而 padding 的部分,直接空过了。batch_sizes则是每次实际读入的数据量,也就是说,RNN 把一个 mini-batch sequence 又重新划分为了很多小的 batch,每个小 batch 为所有 sequence 在当前 time step 对应的值,如果某 sequence 在当前 time step 已经没有值了,那么,就不再读入填充的 0,而是降低 batch_size。
batch_size相当于是对训练数据的重新划分。这也是为什么前面在 collate_fn中我们要对 mini-batch 中的 sequence 按照长度降序排列,是为了方便我们取每个 time step 的batch,防止中间夹杂着 padding。

而每个 mini-batch 中 sequence 的真实 length 又如何获得呢?这就要重新修改
collate_fn了,我们在其中加入data_length=[len(sq) for sq in data] 修改后的代码如下:

def collate_fn(data):
    data.sort(key=lambda x: len(x), reverse=True)
    data_length = [len(sq) for sq in data]
    data = rnn_utils.pad_sequence(data, batch_first=True, padding_value=0)
    return data,data_length

if __name__=='__main__':
    data = MyData(train_x)
    data_loader = DataLoader(data, batch_size=3, shuffle=False,
                             collate_fn=collate_fn)
    batch_x,data_length = iter(data_loader).next()
    batch_x = rnn_utils.pack_padded_sequence(batch_x,[7,6,5],batch_first=True)
    print(batch_x)
    print(data_length)
    print('END')

输出结果:

PackedSequence(data=tensor([1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 1]), batch_sizes=tensor([3, 3, 3, 3, 3, 2, 1]), sorted_indices=None, unsorted_indices=None)
[7, 6, 5]
END
3、pad_packed_sequence

一看名字就知道,这个函数和前面的函数是一对。有点像西游记里的奔波儿灞和灞波儿奔。
上文的例子中,我们为了直观,没有考虑到 RNN 对数据维度的要求,因此在这里我们要重新改写 collate_fn使其返回的数据符合 [batch, sequence_len, input_size]的格式(我们设置网络为 batch_first的模式,更符合习惯)。在例子中,每个 sequence 的元素维度都是1,因此只需要在 tensor 末尾加一维就好了,即对返回的数据 unsqueeze(-1) 一下(也可以在数据库的类中,对 _getitem_的返回值 unsqueeze)。

总结:
(1)我们之前并没有考虑到RNN的输入格式,需要转变格式为[batch, sequence_len, input_size],即batch_first = True
(2)因为我们之前的数据并没有input_size这一项,所以直接在最后加一项1即可,即使用unsqueeze进行加一列

代码:

def collate_fn(data):
    data.sort(key=lambda x: len(x), reverse=True)
    data_length = [len(sq) for sq in data]
    data = rnn_utils.pad_sequence(data, batch_first=True, padding_value=0)
    # print(data)
    return data.unsqueeze(-1),data_length

if __name__=='__main__':
    data = MyData(train_x)
    data_loader = DataLoader(data, batch_size=3, shuffle=False,
                             collate_fn=collate_fn)
    batch_x,data_length = iter(data_loader).next()
    batch_x = rnn_utils.pack_padded_sequence(batch_x,[7,6,5],batch_first=True)
    print(batch_x)
    print(data_length)
    print('END')

PackedSequence(data=tensor([[1],
[2],
[3],
[1],
[2],
[3],
[1],
[2],
[3],
[1],
[2],
[3],
[1],
[2],
[3],
[1],
[2],
[1]]), batch_sizes=tensor([3, 3, 3, 3, 3, 2, 1]), sorted_indices=None, unsorted_indices=None)
END

相当于输出:1,2,3 1,2,3。。。。

接下来,我们随机初始化 hidden state 和 cell state (维度为:num_layers * num_directions, batch, hidden_size), 和batch_x_pack一起送入LSTM中。

if __name__=='__main__':
    data = MyData(train_x)
    data_loader = DataLoader(data, batch_size=3, shuffle=True,
                             collate_fn=collate_fn)
    batch_x, batch_x_len = iter(data_loader).next()
    print(batch_x)
    batch_x_pack = rnn_utils.pack_padded_sequence(batch_x,
                                                  batch_x_len, batch_first=True)

    net = nn.LSTM(1, 10, 2, batch_first=True)
    h0 = torch.rand(2, 3, 10)
    #随机初始化h0,在2~3之间,初始10个数
    c0 = torch.rand(2, 3, 10)
    out, (h1, c1) = net(batch_x_pack, (h0, c0))

其中 LSTM 输入为 1 维,hidden size 为 10 ,总共两层。经过一次前向传播,我们得到 out。out 和 batch_x_pack一样,分为两部分: data 和 batch_sizes。观察一下它这两部分:

   print(out.data.shape)
    print(batch_x_pack.data.shape)
    print(out.batch_sizes)
    print(batch_x_pack.batch_sizes)
    print('END')

结果:

tensor([[[3.],
[3.],
[3.],
[3.],
[3.]],
[[6.],
[6.],
[0.],
[0.],
[0.]],
[[7.],
[0.],
[0.],
[0.],
[0.]]])
torch.Size([8, 10])
torch.Size([8, 1])
tensor([3, 2, 1, 1, 1])
tensor([3, 2, 1, 1, 1])

说明:往模型里面喂的数据是:[33333],[66],[7] size=[81]
经过一层后数据变为[8
10]
输入的批次是[3(3,6,7),2(2,6),1(7),…]
经过一层后批次没变,还是这么输入的

输入的 mini-batch 中,统计所有 time step 共有 14 个非零的数据,而 LSTM 的 hidden unit 有10维,故 out.data.shape为 torch.Size([14, 10])。而out.batch_sizes则和 batch_x_pack.batch_sizes相同,都是 tensor([3, 3, 2, 2, 2, 1, 1])。

pad_packed_sequence 执行的是 pack_padded_sequence 的逆操作,执行下面的代码,观察输出。

print("outpad:")
    out_pad, out_len = rnn_utils.pad_packed_sequence(out, batch_first=True)
    print(out_pad.shape)
    print("out")
    print(out.data.shape)
    print(out_len)

结果:

tensor([[[4.],
[4.],
[4.],
[4.]],
[[5.],
[5.],
[5.],
[0.]],
[[7.],
[0.],
[0.],
[0.]]])

outpad:
torch.Size([3, 4, 10])
out
torch.Size([8, 10])
tensor([4, 3, 1])

解释:
可以看出pad_packed_sequence将数据解压缩了
原数据:[4444],[555],[7]
本来数据隐藏层是810经过解压缩,变成了34*10(末尾加0)
tensor三维也变成了[4,3,1](末尾不加0)

我们发现,经过这样的操作后out_pad 形状变成了[3, 7, 10],仿佛我们直接输入加了padding 的 mini-batch ,mini-batch 中有 3 个 sequence,每个 sequence 有 7 个 time step,每个 time step 数据从输入的 1 维,映射成 LSTM 的 10 维,此外它还输出了 out_len,为 [7, 5, 2],即每个 sequence 的真实长度。 为了放心,我们再看一下out_pad[1]是什么:

tensor([[[3.],
         [3.],
         [3.],
         [3.],
         [3.]],

        [[4.],
         [4.],
         [4.],
         [4.],
         [0.]],

        [[6.],
         [6.],
         [0.],
         [0.],
         [0.]]])
tensor([[ 0.0637,  0.0351,  0.0518,  0.0909, -0.0736,  0.1846,  0.1586,  0.1439,
          0.1500,  0.1125],
        [ 0.0596,  0.0215, -0.0283,  0.0996, -0.2295,  0.2079,  0.1067,  0.0707,
          0.0809,  0.1006],
        [ 0.0633,  0.0118, -0.0592,  0.1024, -0.3106,  0.1751,  0.0915,  0.0189,
          0.0553,  0.1168],
        [ 0.0670,  0.0054, -0.0690,  0.1071, -0.3524,  0.1528,  0.0838, -0.0115,
          0.0442,  0.1351],
        [ 0.0000,  0.0000,  0.0000,  0.0000,  0.0000,  0.0000,  0.0000,  0.0000,
          0.0000,  0.0000]], grad_fn=<SelectBackward>)

Process finished with exit code 0

out_pad[1]实质上是4的维度上的[7,10],所以最后一行为全0,说明4仅在最后补了0

3、整体代码

import torch
from torch import nn
import torch.nn.utils.rnn as rnn_utils
from torch.utils.data import DataLoader
import torch.utils.data as data

train_x = [torch.Tensor([1, 1, 1, 1, 1, 1, 1]),
           torch.Tensor([2, 2, 2, 2, 2, 2]),
           torch.Tensor([3, 3, 3, 3, 3]),
           torch.Tensor([4, 4, 4, 4]),
           torch.Tensor([5, 5, 5]),
           torch.Tensor([6, 6]),
           torch.Tensor([7])
           ]

x = rnn_utils.pad_sequence(train_x, batch_first=True)


class MyData(data.Dataset):
    def __init__(self, data_seq):
        self.data_seq = data_seq

    def __len__(self):
        return len(self.data_seq)

    def __getitem__(self, idx):
        return self.data_seq[idx]


def collate_fn(data):
    data.sort(key=lambda x: len(x), reverse=True)
    data_length = [len(sq) for sq in data]
    data = rnn_utils.pad_sequence(data, batch_first=True, padding_value=0)
    return data.unsqueeze(-1), data_length


if __name__=='__main__':
    data = MyData(train_x)
    data_loader = DataLoader(data, batch_size=3, shuffle=True,
                             collate_fn=collate_fn)
    batch_x, batch_x_len = iter(data_loader).next()
    batch_x_pack = rnn_utils.pack_padded_sequence(batch_x,
                                                  batch_x_len, batch_first=True)

    net = nn.LSTM(1, 10, 2, batch_first=True)
    h0 = torch.rand(2, 3, 10)
    c0 = torch.rand(2, 3, 10)
    out, (h1, c1) = net(batch_x_pack, (h0, c0))
    out_pad, out_len = rnn_utils.pad_packed_sequence(out, batch_first=True)
    print('END')

4、总结

上面三个函数相互配合,可以在 sequence 长度变化时,成批读入数据,训练 RNN。第一个函数用于给 mini-batch 中的数据加 padding,让 mini-batch 中所有 sequence 的长度等于该 mini-batch 中最长的那个 sequence 的长度。
第二、三个函数,用于提高效率,避免 LSTM 前向传播时,把加入在训练数据中的 padding 考虑进去。因此第二、三个函数理论上可以不用,但为了提高效率最好还是用。
除此之外,本文还介绍了 DataLoader的collate_fn参数,用于把 Dataset类的 getitem 方法的返回的 batchsize 个值拼接成一个 tensor。

  • 11
    点赞
  • 30
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值