为什么我们 "pack" PyTorch 中的序列?

Why do we "pack" the sequences in PyTorch?

我试图复制 How to use packing for variable-length sequence inputs for rnn 但我想我首先需要了解为什么我们需要“打包”序列。

我明白我们为什么要“填充”它们,但为什么“包装”(通过 pack_padded_sequence)是必要的?

我也偶然发现了这个问题,下面是我想出来的。

在训练 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]))

添加到 Umang 的回答中,我发现这一点很重要。

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

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

例如,给定数据 abcx :class:PackedSequence 将包含数据 axbc batch_sizes=[2,1,1].

我使用包填充序列如下。

packed_embedded = nn.utils.rnn.pack_padded_sequence(seq, text_lengths)
packed_output, hidden = self.rnn(packed_embedded)

其中 text_lengths 是填充前单个序列的长度,序列根据给定批次中的长度降序排序。

你可以查看示例 here

并且我们进行打包,以便 RNN 在处理序列时不会看到不需要的填充索引,这会影响整体性能。

以上回答很好地解决了为什么这个问题。我只想添加一个示例以更好地理解 pack_padded_sequence.

的用法

举个例子

Note: pack_padded_sequence requires sorted sequences in the batch (in the descending order of sequence lengths). In the below example, the sequence batch were already sorted for less cluttering. Visit this gist link for the full implementation.

首先,我们创建一批 2 个不同序列长度的序列,如下所示。我们总共有 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。它 returns 两个张量的元组:

  • 首先是包含序列批次中所有元素的数据。
  • 第二个是 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>)))
"""

We need to convert output back to the padded batch of 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两种方式导致填充元素的值不同。

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


TL;DR:主要是为了节省计算量。因此,训练神经网络模型所需的时间也(大大)减少了,尤其是在非常大的(a.k.a。web-scale)数据集上进行时。


假设我们总共有 6 个序列(长度可变)。您还可以将此数字 6 视为 batch_size 超参数。 (batch_size会根据序列的长度变化(参见下图2))

现在,我们想将这些序列传递给一些递归神经网络架构。为此,我们必须将批次中的所有序列(通常为 0s)填充到批次中的最大序列长度(max(sequence_lengths)),在下图中为 [=18] =].

那么,数据准备工作应该已经完成​​了吧?不是真的.. 因为还有一个紧迫的问题,主要是与实际需要的计算相比,我们必须做多少计算。

为了便于理解,我们还假设我们将矩阵乘以形状 (6, 9) 的上述 padded_batch_of_sequences 与形状 (9, 3) 的权重矩阵 W

因此,我们将不得不执行6x9 = 54乘法6x8 = 48加法 (nrows x (n-1)_cols) 操作,只是为了丢弃大部分计算结果,因为它们将是 0s(我们有垫)。这种情况下实际需要的计算如下:

 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) 

即使对于这个非常简单的 (toy) 示例,也可以节省很多。您现在可以想象使用 pack_padded_sequence() 可以节省多少计算量(最终:成本、能源、时间、碳排放等),用于具有数百万个条目的大型张量,以及全世界数以百万计的系统一次又一次地这样做再次.

pack_padded_sequence()的功能可以从下图中理解,借助于使用过的color-coding:

作为使用 pack_padded_sequence() 的结果,我们将得到一个张量元组,其中包含 (i) 展平的(上图中沿轴 1)sequences,(ii)相应的批量大小,tensor([6,6,5,4,3,3,2,2,1]) 上面的例子。

然后可以将数据张量(即展平序列)传递给 objective 函数,例如用于损失计算的 CrossEntropy。


1 图片来自 @sgrvinod