por que “empacotamos” as sequências em pytorch?

99

Eu estava tentando replicar como usar o empacotamento para entradas de sequência de comprimento variável para rnn, mas acho que primeiro preciso entender por que precisamos "empacotar" a sequência.

Eu entendo por que precisamos "preenchê-los", mas por que "empacotar" (através pack_padded_sequence) é necessário?

Qualquer explicação de alto nível seria apreciada!

Aerin
fonte
todas as perguntas sobre como embalar em pytorch: discuss.pytorch.org/t/…
Charlie Parker

Respostas:

92

Também me deparei com esse problema e abaixo está o que descobri.

Ao treinar RNN (LSTM ou GRU ou vanilla-RNN), é difícil agrupar as sequências de comprimento variável. Por exemplo: se o comprimento das sequências em um lote de tamanho 8 for [4,6,8,5,4,3,7,8], você preencherá todas as sequências e isso resultará em 8 sequências de comprimento 8. Você acabaria fazendo 64 cálculos (8x8), mas você precisava fazer apenas 45 cálculos. Além disso, se você quisesse fazer algo sofisticado como usar um RNN bidirecional, seria mais difícil fazer cálculos em lote apenas preenchendo e você poderia acabar fazendo mais cálculos do que o necessário.

Em vez disso, o PyTorch nos permite empacotar a seqüência, a seqüência empacotada internamente é uma tupla de duas listas. Um contém os elementos das sequências. Os elementos são intercalados por etapas de tempo (veja o exemplo abaixo) e outro contém o tamanho de cada sequência e o tamanho do lote em cada etapa. Isso é útil para recuperar as sequências reais e também para informar ao RNN qual é o tamanho do lote em cada etapa de tempo. Isso foi apontado por @Aerin. Isso pode ser passado para RNN e otimizará internamente os cálculos.

Posso não ter sido claro em alguns pontos, então me avise e posso adicionar mais explicações.

Aqui está um exemplo de código:

 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 Gupta
fonte
4
Você pode explicar por que a saída do exemplo fornecido é PackedSequence (data = tensor ([1, 3, 2, 4, 3]), batch_sizes = tensor ([2, 2, 1]))?
ascetic652
3
A parte de dados são apenas todos os tensores concatenados ao longo do eixo do tempo. Batch_size é na verdade a matriz de tamanhos de lote em cada etapa de tempo.
Umang Gupta
3
O batch_sizes = [2, 2, 1] representa o agrupamento [1, 3] [2, 4] e [3] respectivamente.
Chaitanya Shivade
@ChaitanyaShivade porque é o tamanho do lote [2,2,1]? não pode ser [1,2,2]? qual é a lógica por trás disso?
Programador Annonymous
1
Porque na etapa t, você só pode processar vetores na etapa t, se você mantiver os vetores ordenados como [1,2,2], você provavelmente está colocando cada entrada como um lote, mas que não pode ser paralelizado e, portanto, não batchable
Umang Gupta
60

Aqui estão algumas explicações visuais 1 que podem ajudar a desenvolver uma melhor intuição para a funcionalidade dopack_padded_sequence()

Vamos supor que temos 6sequências (de comprimentos variáveis) no total. Você também pode considerar esse número 6como um batch_sizehiperparâmetro. (O batch_sizeirá variar dependendo do comprimento da sequência (cf. Fig.2 abaixo))

Agora, queremos passar essas sequências para algumas arquiteturas de rede neural recorrente. Para fazer isso, temos que preencher todas as sequências (normalmente com 0s) em nosso lote até o comprimento máximo de sequência em nosso lote ( max(sequence_lengths)), que na figura abaixo é 9.

padded-seqs

Então, o trabalho de preparação de dados deve estar concluído agora, certo? Na verdade não .. Porque ainda há um problema urgente, principalmente em termos de quanta computação temos que fazer em comparação com os cálculos realmente necessários.

Para fins de compreensão, também vamos supor que vamos multiplicar a matriz acima padded_batch_of_sequencesda forma (6, 9)por uma matriz Wde peso da forma (9, 3).

Assim, teremos que realizar operações de 6x9 = 54multiplicação e 6x8 = 48adição                     ( nrows x (n-1)_cols), apenas para descartar a maioria dos resultados calculados, pois eles seriam 0s (onde temos pads). O cálculo real necessário neste caso é o seguinte:

 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) 

Isso é muito mais economia, mesmo para este exemplo muito simples ( brinquedo ). Agora você pode imaginar quanta computação (eventualmente: custo, energia, tempo, emissão de carbono etc.) pode ser economizada usando pack_padded_sequence()para grandes tensores com milhões de entradas, e mais de milhões de sistemas em todo o mundo fazendo isso, novamente e novamente.

A funcionalidade do pack_padded_sequence()pode ser compreendida na figura abaixo, com a ajuda da codificação por cores utilizada:

pack-padded-seqs

Como resultado do uso pack_padded_sequence(), obteremos uma tupla de tensores contendo (i) o achatado (ao longo do eixo 1, na figura acima) sequences, (ii) os tamanhos de lote correspondentes, tensor([6,6,5,4,3,3,2,2,1])para o exemplo acima.

O tensor de dados (ou seja, as sequências achatadas) pode então ser passado para funções objetivo, como CrossEntropy para cálculos de perda.


1 crédito de imagem para @sgrvinod

kmario23
fonte
2
Diagramas excelentes!
David Waterworth,
1
Edit: Eu acho que stackoverflow.com/a/55805785/6167850 (abaixo) responde minha pergunta, que deixarei aqui de qualquer maneira: ~ Isso significa essencialmente que os gradientes não são propagados para as entradas preenchidas? E se minha função de perda for calculada apenas no estado / saída final oculta do RNN? Os ganhos de eficiência devem ser jogados fora então? Ou a perda será calculada a partir da etapa anterior ao início do preenchimento, que é diferente para cada elemento de lote neste exemplo? ~
nlml
28

As respostas acima abordaram muito bem a questão do porquê . Eu só quero adicionar um exemplo para entender melhor o uso de pack_padded_sequence.

Vamos dar um exemplo

Nota: pack_padded_sequencerequer sequências classificadas no lote (na ordem decrescente dos comprimentos da sequência). No exemplo abaixo, o lote da sequência já foi classificado para menos desordem. Visite este link principal para a implementação completa.

Primeiro, criamos um lote de 2 sequências de comprimentos de sequência diferentes, conforme abaixo. Temos 7 elementos no lote totalmente.

  • Cada sequência tem um tamanho de incorporação de 2.
  • A primeira sequência tem o comprimento: 5
  • A segunda sequência tem o comprimento: 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]

Nós preenchemos seq_batchpara obter o lote de sequências com comprimento igual a 5 (o comprimento máximo no lote). Agora, o novo lote conta com 10 elementos no total.

# 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]]])
"""

Em seguida, embalamos o padded_seq_batch. Ele retorna uma tupla de dois tensores:

  • O primeiro são os dados que incluem todos os elementos no lote de sequência.
  • O segundo é o batch_sizesque dirá como os elementos se relacionam entre si pelas etapas.
# 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]))
"""

Agora, passamos a tupla packed_seq_batchpara os módulos recorrentes no Pytorch, como RNN, LSTM. Isso requer apenas 5 + 2=7cálculos no módulo recorrente.

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>)))
"""

Precisamos converter de outputvolta para o lote preenchido de saída:

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])
"""

Compare este esforço com a forma padrão

  1. Na forma padrão, só precisamos passar o módulo padded_seq_batchpara lstm. No entanto, requer 10 cálculos. Envolve vários cálculos mais em elementos de preenchimento que seriam computacionalmente ineficientes.

  2. Observe que isso não leva a representações imprecisas , mas precisa de muito mais lógica para extrair representações corretas.

    • Para LSTM (ou quaisquer módulos recorrentes) com apenas direção direta, se quisermos extrair o vetor oculto da última etapa como uma representação para uma sequência, teríamos que pegar vetores ocultos de T (th) etapa, onde T é o comprimento da entrada. Pegar a última representação será incorreto. Observe que T será diferente para diferentes entradas no lote.
    • Para LSTM bidirecional (ou quaisquer módulos recorrentes), é ainda mais complicado, pois seria necessário manter dois módulos RNN, um que funciona com preenchimento no início da entrada e outro com preenchimento no final da entrada, e finalmente extraindo e concatenando os vetores ocultos conforme explicado acima.

Vamos ver a diferença:

# 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 >))
"""

Os resultados acima mostram que hn, cnsão diferentes de duas maneiras, enquanto as outputduas maneiras levam a valores diferentes para elementos de preenchimento.

David Ng
fonte
2
Boa resposta! Apenas uma correção, se você fizer o preenchimento, não deve usar o último h em vez de h no índice igual ao comprimento da entrada. Além disso, para fazer RNN bidirecional, você gostaria de usar dois RNN diferentes - um com preenchimento na frente e outro com preenchimento na parte traseira para obter resultados corretos. O preenchimento e a seleção da última saída estão "errados". Portanto, seus argumentos de que isso leva a uma representação imprecisa estão errados. O problema com o preenchimento é que ele está correto, mas ineficiente (se houver opção de sequências compactadas) e pode ser complicado (por exemplo: bi-dir RNN)
Umang Gupta
18

Somando-se à resposta de Umang, achei importante observar isso.

O primeiro item na tupla retornada de pack_padded_sequenceé um tensor de dados (tensor) contendo a sequência compactada. O segundo item é um tensor de inteiros contendo informações sobre o tamanho do lote em cada etapa da sequência.

O que é importante aqui, porém, é que o segundo item (tamanhos de lote) representa o número de elementos em cada etapa da sequência no lote, não os comprimentos de sequência variados passados pack_padded_sequence.

Por exemplo, dados fornecidos abce x : class: PackedSequenceconteria dados axbccom batch_sizes=[2,1,1].

Aerin
fonte
1
Obrigado, esqueci completamente disso. e cometi um erro na minha resposta ao atualizar isso. No entanto, olhei para a segunda sequência como alguns dados necessários para recuperar as sequências e é por isso que bagunçou a minha descrição
Umang Gupta
3

Usei a sequência preenchida do pacote da seguinte forma.

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

onde text_lengths são o comprimento da sequência individual antes do preenchimento e da sequência serem classificados de acordo com a ordem decrescente de comprimento em um determinado lote.

você pode verificar um exemplo aqui .

E fazemos o empacotamento para que o RNN não veja o índice preenchido indesejado enquanto processa a sequência que afetaria o desempenho geral.

Jibin Mathew
fonte