dlaczego „pakujemy” sekwencje w pytorch?

93

Próbowałem powtórzyć Jak używać pakowania dla danych wejściowych sekwencji o zmiennej długości dla rnn, ale myślę, że najpierw muszę zrozumieć, dlaczego musimy „spakować” sekwencję.

Rozumiem, dlaczego musimy je „wypełniać”, ale dlaczego konieczne jest „pakowanie” (przez pack_padded_sequence)?

Wszelkie wyjaśnienia na wysokim poziomie będą mile widziane!

Aerin
źródło
wszystkie pytania dotyczące pakowania w pytorcha: discuss.pytorch.org/t/…
Charlie Parker

Odpowiedzi:

88

Natknąłem się również na ten problem i poniżej jest to, co odkryłem.

Podczas szkolenia RNN (LSTM lub GRU lub vanilla-RNN) trudno jest grupować sekwencje o zmiennej długości. Na przykład: jeśli długość sekwencji w partii o rozmiarze 8 wynosi [4,6,8,5,4,3,7,8], wypełnisz wszystkie sekwencje, co da 8 sekwencji o długości 8. Możesz skończyłoby się na wykonaniu 64 obliczeń (8x8), ale trzeba było wykonać tylko 45 obliczeń. Co więcej, jeśli chciałbyś zrobić coś wymyślnego, na przykład użycie dwukierunkowego RNN, trudniej byłoby wykonać obliczenia wsadowe tylko przez wypełnienie i możesz w końcu wykonać więcej obliczeń niż jest to wymagane.

Zamiast tego PyTorch pozwala nam spakować sekwencję, wewnętrznie spakowana sekwencja jest krotką dwóch list. Jedna zawiera elementy sekwencji. Elementy są przeplatane krokami czasowymi (patrz przykład poniżej), a inne zawierają rozmiar każdej sekwencji, rozmiar wsadu na każdym etapie. Jest to pomocne w odtwarzaniu rzeczywistych sekwencji, a także w informowaniu RNN o wielkości partii na każdym etapie czasowym. Wskazał na to @Aerin. Można to przekazać do RNN i wewnętrznie zoptymalizuje obliczenia.

W niektórych momentach mogłem być niejasny, więc daj mi znać, a będę mógł dodać więcej wyjaśnień.

Oto przykład kodu:

 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
źródło
4
Czy możesz wyjaśnić, dlaczego dane wyjściowe podanego przykładu to PackedSequence (dane = tensor ([1, 3, 2, 4, 3]), batch_sizes = tensor ([2, 2, 1]))?
ascetic652
3
Część danych to po prostu wszystkie tensory połączone wzdłuż osi czasu. Batch_size to w rzeczywistości tablica rozmiarów partii w każdym kroku czasowym.
Umang Gupta
2
Batch_sizes = [2, 2, 1] reprezentuje odpowiednio grupowanie [1, 3] [2, 4] i [3].
Chaitanya Shivade
@ChaitanyaShivade, dlaczego rozmiar partii [2,2,1]? czy to nie może być [1,2,2]? jaka jest logika za tym?
Anonimowy programista
1
Ponieważ w kroku t możesz przetwarzać wektory tylko w kroku t, jeśli utrzymujesz wektory uporządkowane jako [1, 2, 2], prawdopodobnie umieszczasz każde wejście jako wsad, ale tego nie można zrównoleglać, a zatem nie można go wsadować
Umang Gupta
51

Oto kilka wizualnych wyjaśnień 1, które mogą pomóc w opracowaniu lepszej intuicji w zakresie działaniapack_padded_sequence()

Załóżmy, że mamy 6w sumie sekwencje (o zmiennej długości). Możesz również uznać tę liczbę 6za batch_sizehiperparametr.

Teraz chcemy przekazać te sekwencje do niektórych powtarzających się architektur sieci neuronowych. Aby to zrobić, musimy dopełnić wszystkie sekwencje (zwykle 0s) w naszej partii do maksymalnej długości sekwencji w naszej batch ( max(sequence_lengths)), która na poniższym rysunku wynosi 9.

padded-seqs

Zatem prace nad przygotowaniem danych powinny być już zakończone, prawda? Niezupełnie ... Ponieważ wciąż istnieje jeden palący problem, głównie w kwestii tego, ile obliczeń musimy wykonać w porównaniu z faktycznie wymaganymi obliczeniami.

W trosce o zrozumienie, przyjmijmy, że będziemy matrix pomnożyć powyższe padded_batch_of_sequenceskształtu (6, 9)z matrycą wagi Wkształtu (9, 3).

W związku z tym będziemy musieli wykonywać operacje 6x9 = 54mnożenia i 6x8 = 48dodawania                     ( nrows x (n-1)_cols), tylko po to, aby wyrzucić większość obliczonych wyników, ponieważ byłyby one 0s (gdzie mamy pola ). Rzeczywiste wymagane obliczenia w tym przypadku są następujące:

 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) 

To DUŻO więcej oszczędności, nawet jak na ten bardzo prosty ( zabawkowy ) przykład. Możesz sobie teraz wyobrazić, ile mocy obliczeniowej (ostatecznie: koszt, energia, czas, emisja dwutlenku węgla itp.) Można zaoszczędzić, używając pack_padded_sequence()dużych tensorów z milionami wpisów i milionów systemów na całym świecie, które robią to wielokrotnie.

Funkcjonalność pack_padded_sequence()można zrozumieć na poniższym rysunku, za pomocą zastosowanego kodowania kolorami:

pack-padded-seqs

W wyniku użycia pack_padded_sequence()otrzymamy krotkę tensorów zawierającą (i) spłaszczone (wzdłuż osi-1, na powyższym rysunku) sequences, (ii) odpowiednie rozmiary partii, tensor([6,6,5,4,3,3,2,2,1])dla powyższego przykładu.

Tensor danych (tj. Spłaszczone sekwencje) można następnie przekazać do funkcji celu, takich jak CrossEntropy, w celu obliczenia strat.


1 kredyty obrazkowe dla @sgrvinod

kmario23
źródło
2
Doskonałe diagramy!
David Waterworth
1
Edycja: Myślę, że stackoverflow.com/a/55805785/6167850 (poniżej) odpowiada na moje pytanie, które i tak zostawię tutaj: ~ Czy to zasadniczo oznacza, że ​​gradienty nie są propagowane na wypełnione wejścia? Co się stanie, jeśli moja funkcja utraty jest obliczana tylko na ostatnim ukrytym stanie / wyjściu RNN? Czy w takim razie należy odrzucić wzrost wydajności? Czy może strata zostanie obliczona na podstawie kroku poprzedzającego rozpoczęcie wypełniania, który jest inny dla każdego elementu wsadu w tym przykładzie? ~
nlml
25

Powyższe odpowiedzi odpowiadały na pytanie, dlaczego bardzo dobrze. Chcę tylko dodać przykład, aby lepiej zrozumieć użycie pack_padded_sequence.

Weźmy przykład

Uwaga: pack_padded_sequencewymaga posortowanych sekwencji w partii (w kolejności malejącej długości sekwencji). W poniższym przykładzie partia sekwencji została już posortowana pod kątem mniejszego bałaganu. Odwiedź ten link z treścią, aby uzyskać pełną implementację.

Najpierw tworzymy partię 2 sekwencji o różnych długościach sekwencji, jak poniżej. Mamy w zestawie łącznie 7 elementów.

  • Każda sekwencja ma rozmiar osadzania 2.
  • Pierwsza sekwencja ma długość: 5
  • Druga sekwencja ma długość: 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]

Podkładamy, seq_batchaby uzyskać partię sekwencji o równej długości 5 (maksymalna długość w partii). Teraz nowa partia ma w sumie 10 elementów.

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

Następnie pakujemy padded_seq_batch. Zwraca krotkę dwóch tensorów:

  • Pierwsza to dane zawierające wszystkie elementy w partii sekwencji.
  • Drugi to, batch_sizesktóry powie, w jaki sposób elementy są ze sobą powiązane za pomocą kroków.
# 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]))
"""

Teraz przekazujemy krotkę packed_seq_batchdo powtarzających się modułów w Pytorch, takich jak RNN, LSTM. Wymaga to jedynie 5 + 2=7obliczeń w module rekurencyjnym.

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

Musimy przekonwertować z outputpowrotem na wypełnioną partię danych wyjściowych:

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

Porównaj ten wysiłek ze standardowym sposobem

  1. W standardowy sposób musimy tylko przekazać moduł padded_seq_batchdo lstm. Wymaga jednak 10 obliczeń. Obejmuje kilka obliczeń więcej na elementach wypełniających, co byłoby nieefektywne obliczeniowo .

  2. Należy zauważyć, że nie prowadzi to do niedokładnych reprezentacji, ale wymaga znacznie więcej logiki, aby wyodrębnić prawidłowe reprezentacje.

    • Dla LSTM (lub dowolnych powtarzających się modułów) z tylko kierunkiem do przodu, jeśli chcielibyśmy wyodrębnić ukryty wektor ostatniego kroku jako reprezentację dla sekwencji, musielibyśmy pobrać ukryte wektory z T (tego) kroku, gdzie T jest długością wejścia. Podjęcie ostatniej reprezentacji będzie nieprawidłowe. Zauważ, że T będzie różne dla różnych wejść w partii.
    • W przypadku dwukierunkowego LSTM (lub dowolnych powtarzających się modułów) jest to jeszcze bardziej kłopotliwe, ponieważ należałoby utrzymywać dwa moduły RNN, jeden działający z dopełnieniem na początku wejścia, a drugi z wypełnieniem na końcu wejścia, ostatecznie wyodrębnianie i konkatenacja ukrytych wektorów, jak wyjaśniono powyżej.

Zobaczmy różnicę:

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

Powyższe wyniki wskazują, że hn, cnsą różne w obu sposobów, podczas gdy outputz dwóch sposobów prowadzić do różnych wartości elementów wypełniających.

David Ng
źródło
2
Niezła odpowiedź! Tylko poprawka, jeśli robisz dopełnienie, nie powinieneś używać ostatniej godziny zamiast h na indeksie równym długości wejścia. Ponadto, aby wykonać dwukierunkowy RNN, chciałbyś użyć dwóch różnych RNN --- jeden z wypełnieniem z przodu i drugi z wypełnieniem z tyłu, aby uzyskać prawidłowe wyniki. Wypełnianie i wybieranie ostatniego wyjścia jest „nieprawidłowe”. Więc twoje argumenty, że prowadzi to do niedokładnej reprezentacji, są błędne. Problem z dopełnieniem polega na tym, że jest poprawny, ale nieefektywny (jeśli jest opcja upakowanych sekwencji) i może być uciążliwy (na przykład: bi-dir RNN)
Umang Gupta
18

Dodając do odpowiedzi Umanga, uznałem to za ważne.

Pierwsza pozycja w zwróconej krotce pack_padded_sequenceto data (tensor) - tensor zawierający upakowany ciąg. Drugą pozycją jest tensor liczb całkowitych przechowujących informacje o wielkości partii w każdym kroku sekwencji.

Ważna jest tutaj jednak druga pozycja (rozmiary partii) reprezentująca liczbę elementów w każdym kroku sekwencji w partii, a nie różne długości sekwencji przekazane do pack_padded_sequence.

Na przykład podane dane abci x : class: PackedSequencezawierałyby dane axbcz rozszerzeniem batch_sizes=[2,1,1].

Aerin
źródło
1
Dzięki, całkowicie o tym zapomniałem. i popełniłem błąd w mojej odpowiedzi, aby to zaktualizować. Jednak spojrzałem na drugą sekwencję jako na pewne dane potrzebne do odzyskania sekwencji i dlatego
zawiodłem
2

Użyłem sekwencji wyściełanej plecaka w następujący sposób.

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

gdzie długość_tekstu to długość pojedynczej sekwencji przed wypełnieniem, a sekwencja jest sortowana według malejącej kolejności długości w danej partii.

możesz sprawdzić przykład tutaj .

I robimy pakowanie, aby RNN nie widział niechcianego, wypełnionego indeksu podczas przetwarzania sekwencji, która wpłynęłaby na ogólną wydajność.

Jibin Mathew
źródło