为什么我们要在PyTorch中“打包”序列?

173
我试图复制如何在rnn中使用变长序列输入进行打包,但我想我首先需要了解为什么我们需要“打包”序列。
我明白为什么我们需要将它们“填充”,但是为什么需要通过pack_padded_sequence进行“打包”呢?

所有关于PyTorch中打包的问题:https://discuss.pytorch.org/t/why-do-we-need-to-pack-padded-batches-of-sequences-in-pytorch/47977 - Charlie Parker
5个回答

156

我也遇到了这个问题,以下是我找出的解决方法。

在训练 RNN(LSTM、GRU 或 vanilla-RNN)时,很难对变长序列进行批处理。例如:如果大小为 8 的一组序列的长度是 [4,6,8,5,4,3,7,8],那么您将填充所有序列,这将导致 8 个长度为 8 的序列。这样您将会做 64 次计算(8x8),但实际上只需要做 45 次计算。此外,如果您想要像使用双向 RNN 这样的花哨操作,仅通过填充进行批量计算就更难了,您可能会做比所需更多的计算。

相反,PyTorch 允许我们打包序列,内部打包的序列是一个包含两个列表的元组。其中一个包含序列的元素。元素按时间步骤交错排列(请参见下面的示例),另一个包含每个步骤的批次大小。这有助于恢复实际序列,并告诉 RNN 每个时间步骤的批次大小。这已经被 @Aerin 指出。可以将其传递给 RNN,它将在内部优化计算。

如果我有些地方不清楚,请让我知道,我可以添加更多的解释。

这是一个代码示例:

 a = [torch.tensor([1,2,3]), torch.tensor([3,4])]
 b = torch.nn.utils.rnn.pad_sequence(a, batch_first=True)
 >>>>
 tensor([[ 1,  2,  3],
    [ 3,  4,  0]])
 torch.nn.utils.rnn.pack_padded_sequence(b, batch_first=True, lengths=[3,2])
 >>>>PackedSequence(data=tensor([ 1,  3,  2,  4,  3]), batch_sizes=tensor([ 2,  2,  1]))

7
你能解释一下为什么给定的例子输出结果是PackedSequence(data=tensor([ 1, 3, 2, 4, 3]), batch_sizes=tensor([ 2, 2, 1]))吗? - ascetic652
7
数据部分实际上就是将所有张量沿时间轴连接起来的结果。Batch_size 实际上是每个时间步骤中批次大小的数组。 - Umang Gupta
11
batch_sizes=[2, 2, 1] 表示将 [1, 3]、[2, 4] 和 [3] 分别分组。 - Chaitanya Shivade
2
因为在步骤t,您只能处理步骤t的向量,如果您将向量按[1,2,2]排序,则可能将每个输入作为批处理放置,但这不能并行化,因此无法进行批处理。 - Umang Gupta
3
这是否意味着打包序列仅用于节省一些计算(因此提高速度/节省能量)?如果仅在填充的序列上进行,且强制0损失,则训练/学习将发生相同的情况吗? - WalksB
显示剩余4条评论

155

这里有一些图示解释1,可能有助于更好地理解pack_padded_sequence()的功能。


TL;DR: 这主要是为了节省计算资源。因此,训练神经网络模型所需的时间也会(大幅)缩短,特别是在处理非常大的(也就是 Web 规模的)数据集时。


假设我们总共有6个序列(长度可变)。您也可以将此数字6视为batch_size超参数。(batch_size将根据序列长度而变化(参见下面的图2))
现在,我们想要将这些序列传递给一些递归神经网络架构。为此,我们必须将批次中的所有序列(通常使用0进行)填充到批次中的最大序列长度(max(sequence_lengths)),在下面的图中为9。

padded-seqs

所以,数据准备工作现在应该完成了,对吧?实际上并不是这样。因为还有一个紧迫的问题,主要是在计算量与实际所需计算之间的比较方面。
为了便于理解,让我们假设我们将以上形状为(6, 9)的填充序列批次与形状为(9, 3)的权重矩阵W进行矩阵乘法运算。
因此,我们将需要执行54次乘法和48次加法(nrows x (n-1)_cols)操作,只是为了丢掉大部分计算结果,因为它们将是0(即填充处)。在这种情况下,实际所需的计算如下:
 9-mult  8-add 
 8-mult  7-add 
 6-mult  5-add 
 4-mult  3-add 
 3-mult  2-add 
 2-mult  1-add
---------------
32-mult  26-add
   
------------------------------  
#savings: 22-mult & 22-add ops  
          (32-54)  (26-48) 

这个(玩具)例子的节省量要多得多。现在你可以想象,对于数百万条目的大张量,以及全球数百万个系统不断使用pack_padded_sequence()可以节省多少计算(最终:成本、能源、时间、碳排放等)。下面的图示使用颜色编码,可以帮助理解pack_padded_sequence()的功能。{{图略}}

pack-padded-seqs

使用pack_padded_sequence()后,我们会得到一个张量元组,其中包含(i) 扁平化的(沿着axis-1,如上图所示)sequences,(ii) 相应的批次大小,对于上面的示例为tensor([6,6,5,4,3,3,2,2,1])
数据张量(即扁平化序列)可以传递给目标函数,例如交叉熵用于损失计算。

1 图片由@sgrvinod提供


7
出色的图表! - David Waterworth
1
编辑:我认为 https://dev59.com/KlUK5IYBdhLWcg3w_z20#55805785(下面)回答了我的问题,但我还是会把它留在这里:这是否基本意味着梯度不会传播到填充输入?如果我的损失函数仅计算RNN的最终隐藏状态/输出,那怎么办?那么效率提高就必须放弃吗?还是损失将从填充开始之前的步骤计算,这对于此示例中的每个批次元素都是不同的? - nlml
1
我对矩阵乘法的实现方式感到好奇,因为RNN的输入应该是顺序的,每次只取打包向量的一部分。这个伟大的教程中提供了完整的解释: https://github.com/sgrvinod/a-PyTorch-Tutorial-to-Sequence-Labeling - Nur L
1
这是一次极好的可视化展示。根据您呈现的内容,我现在开始怀疑为什么我们甚至需要填充。似乎在打包过程中,我们会抛弃填充物。那么为什么一开始还要添加它们呢? - F Gh

50
以上答案很好地回答了问题“为什么”的部分。我想要举一个例子来更好地理解pack_padded_sequence的用法。
让我们举一个例子:
注意:pack_padded_sequence需要对批次中的序列进行排序(按序列长度降序排列)。在下面的示例中,序列批次已经排序,以避免混乱。请访问此gist链接以获取完整实现。
首先,我们创建一个由两个不同长度序列组成的批次,如下所示。我们总共有7个元素。
每个序列具有2的嵌入大小。
第一个序列的长度为:5
第二个序列的长度为:2
import torch 

seq_batch = [torch.tensor([[1, 1],
                           [2, 2],
                           [3, 3],
                           [4, 4],
                           [5, 5]]),
             torch.tensor([[10, 10],
                           [20, 20]])]

seq_lens = [5, 2]

我们对seq_batch进行填充,以获得长度为5的序列批次(批次中的最大长度)。现在,新的批次总共有10个元素。
# pad the seq_batch
padded_seq_batch = torch.nn.utils.rnn.pad_sequence(seq_batch, batch_first=True)
"""
>>>padded_seq_batch
tensor([[[ 1,  1],
         [ 2,  2],
         [ 3,  3],
         [ 4,  4],
         [ 5,  5]],

        [[10, 10],
         [20, 20],
         [ 0,  0],
         [ 0,  0],
         [ 0,  0]]])
"""

然后,我们对 padded_seq_batch 进行打包。它将返回一个由两个张量组成的元组:

  • 第一个张量包含序列批次中的所有元素。
  • 第二个张量是 batch_sizes,它将告诉我们每个元素在步骤中如何相关联。
# pack the padded_seq_batch
packed_seq_batch = torch.nn.utils.rnn.pack_padded_sequence(padded_seq_batch, lengths=seq_lens, batch_first=True)
"""
>>> packed_seq_batch
PackedSequence(
   data=tensor([[ 1,  1],
                [10, 10],
                [ 2,  2],
                [20, 20],
                [ 3,  3],
                [ 4,  4],
                [ 5,  5]]), 
   batch_sizes=tensor([2, 2, 1, 1, 1]))
"""

现在,我们将元组packed_seq_batch传递给Pytorch中的循环模块,例如RNN、LSTM。这仅需要在循环模块中进行5+2=7次计算。

lstm = nn.LSTM(input_size=2, hidden_size=3, batch_first=True)
output, (hn, cn) = lstm(packed_seq_batch.float()) # pass float tensor instead long tensor.
"""
>>> output # PackedSequence
PackedSequence(data=tensor(
        [[-3.6256e-02,  1.5403e-01,  1.6556e-02],
         [-6.3486e-05,  4.0227e-03,  1.2513e-01],
         [-5.3134e-02,  1.6058e-01,  2.0192e-01],
         [-4.3123e-05,  2.3017e-05,  1.4112e-01],
         [-5.9372e-02,  1.0934e-01,  4.1991e-01],
         [-6.0768e-02,  7.0689e-02,  5.9374e-01],
         [-6.0125e-02,  4.6476e-02,  7.1243e-01]], grad_fn=<CatBackward>), batch_sizes=tensor([2, 2, 1, 1, 1]))

>>>hn
tensor([[[-6.0125e-02,  4.6476e-02,  7.1243e-01],
         [-4.3123e-05,  2.3017e-05,  1.4112e-01]]], grad_fn=<StackBackward>),
>>>cn
tensor([[[-1.8826e-01,  5.8109e-02,  1.2209e+00],
         [-2.2475e-04,  2.3041e-05,  1.4254e-01]]], grad_fn=<StackBackward>)))
"""

我们需要将output转换回填充的输出批次:
padded_output, output_lens = torch.nn.utils.rnn.pad_packed_sequence(output, batch_first=True, total_length=5)
"""
>>> padded_output
tensor([[[-3.6256e-02,  1.5403e-01,  1.6556e-02],
         [-5.3134e-02,  1.6058e-01,  2.0192e-01],
         [-5.9372e-02,  1.0934e-01,  4.1991e-01],
         [-6.0768e-02,  7.0689e-02,  5.9374e-01],
         [-6.0125e-02,  4.6476e-02,  7.1243e-01]],

        [[-6.3486e-05,  4.0227e-03,  1.2513e-01],
         [-4.3123e-05,  2.3017e-05,  1.4112e-01],
         [ 0.0000e+00,  0.0000e+00,  0.0000e+00],
         [ 0.0000e+00,  0.0000e+00,  0.0000e+00],
         [ 0.0000e+00,  0.0000e+00,  0.0000e+00]]],
       grad_fn=<TransposeBackward0>)

>>> output_lens
tensor([5, 2])
"""

将此工作与标准方式进行比较

  1. 在标准方式中,我们只需要将 padded_seq_batch 传递给 lstm 模块即可。然而,这需要进行10次计算。它还涉及在填充元素上进行了更多的计算,这会导致计算效率低下

  2. 请注意,这不会导致不准确的表示,但需要更多的逻辑来提取正确的表示。

    • 对于仅具有前向方向的LSTM(或任何循环模块),如果我们想要提取最后一步的隐藏向量作为序列的表示,则必须从T(th)步中挑选出隐藏向量,其中T是输入的长度。选择最后一个表示将是不正确的。请注意,不同输入的T将不同。
    • 对于双向LSTM(或任何循环模块),这更加麻烦,因为我们需要维护两个RNN模块,一个用于处理输入开头的填充,一个用于处理输入结尾的填充,并最终提取并连接上述隐藏向量。

让我们看看区别:

# The standard approach: using padding batch for recurrent modules
output, (hn, cn) = lstm(padded_seq_batch.float())
"""
>>> output
 tensor([[[-3.6256e-02, 1.5403e-01, 1.6556e-02],
          [-5.3134e-02, 1.6058e-01, 2.0192e-01],
          [-5.9372e-02, 1.0934e-01, 4.1991e-01],
          [-6.0768e-02, 7.0689e-02, 5.9374e-01],
          [-6.0125e-02, 4.6476e-02, 7.1243e-01]],

         [[-6.3486e-05, 4.0227e-03, 1.2513e-01],
          [-4.3123e-05, 2.3017e-05, 1.4112e-01],
          [-4.1217e-02, 1.0726e-01, -1.2697e-01],
          [-7.7770e-02, 1.5477e-01, -2.2911e-01],
          [-9.9957e-02, 1.7440e-01, -2.7972e-01]]],
        grad_fn= < TransposeBackward0 >)

>>> hn
tensor([[[-0.0601, 0.0465, 0.7124],
         [-0.1000, 0.1744, -0.2797]]], grad_fn= < StackBackward >),

>>> cn
tensor([[[-0.1883, 0.0581, 1.2209],
         [-0.2531, 0.3600, -0.4141]]], grad_fn= < StackBackward >))
"""

上述结果显示,hncn 有两种不同的方式,而从两种方式中导出的 output 对于填充元素的值也是不同的。

3
很好的回答!只有一个纠正,如果你使用填充(padding),你不应该使用最后一个h而是使用索引等于输入长度的h。此外,要进行双向RNN,您需要使用两个不同的RNN——一个在前面填充,另一个在后面填充以获得正确的结果。 填充和选择最后一个输出是“错误”的。因此,您认为这会导致表示不准确的论点是错误的。填充的问题在于它是正确但效率低下(如果有打包序列选项),并且可能很繁琐(例如:双向RNN)。 - Umang Gupta
我正在尝试理解"请注意,它不会导致不准确的表示"是如何成立的。我认为这个论点是通过将0传递到RNN中不会改变输出,但这似乎只有在所有偏差都等于0的情况下才成立。 - financial_physician

23

补充Umang的回答,我认为这一点很重要。

pack_padded_sequence返回元组的第一项是一个数据(张量)——包含打包序列的张量。第二项是一个整数张量,保存每个序列步骤的批处理大小信息。

这里重要的是第二项(批处理大小)代表批处理中每个序列步骤中的元素数量,而不是传递给pack_padded_sequence的不同序列长度。

例如,给定数据abcxPackedSequence将包含数据axbcbatch_sizes=[2,1,1]


1
谢谢,我完全忘记了。并且在我的回答中犯了一个错误,我会更新的。然而,我将第二个序列视为一些需要恢复序列的数据,这就是为什么我搞糊涂了我的描述。 - Umang Gupta

3
我使用了如下的打包填充序列技术。
packed_embedded = nn.utils.rnn.pack_padded_sequence(seq, text_lengths)
packed_output, hidden = self.rnn(packed_embedded)

在一个给定的批次中,按照序列长度的降序排序,并将文本长度作为填充之前各个序列的长度。你可以在这里查看一个示例。(点击这里)我们进行数据压缩,以便在处理序列时,RNN不会看到不需要的填充索引,从而影响整体性能。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接