aitech-moj-2023/wyk/15_Pozycyjne_zanurzenia.ipynb

16 KiB

Logo 1

Modelowanie języka

15. Sieci Transformer — pozycyjne zanurzenia [wykład]

Filip Graliński (2022)

Logo 2

Pozycyjne zanurzenia

Atencja nie uwzględnia kolejności wyrazów

W przeciwieństwie do sieci rekurencyjnych sama atencja nie ma naturalnego, wbudowanego pojęcia kolejności, porządku czy „strzałki czasu”. Oznacza to, że sieci Transformer w postaci przedstawionej do tej pory właściwie operowałyby na tekście jak na worku słów (dowolna permutacja tekstu dawałaby identyczny wynik.

Oznacza to, że pozycje wyrazów (tokenów) muszą być w jakiś sposób, celowo „wstrzyknięte” do sieci Transformer. Standardowa procedura polega na uzupełnieniu zanurzeń o element pozycyjny. Taki element sieci neuronowej nazywamy zanurzeniami (embeddingami) pozycyjnymi (_position(al) embeddings, encodings).

Rodzaje zanurzeń pozycyjnych

Opracowano kilka różnych typów embeddingów pozycyjnych, najczęściej stosowane:

  • właściwe zanurzenia pozycyjne zależne wprost od bezwzględnej pozycji tokena w zdaniu
    • wyuczalne embeddingi pozycyjne
    • embeddingi sinusoidalne
  • zanurzenia względne,
  • zanurzenia obrotowe.

Zanurzenia zależne od bezwzględnej pozycji tokena

Najprostszy sposób uwzględnienia pozycji tokena polega na zdefiniowaniu pewnej funkcji $E_p(i) \colon \mathcal{N} \rightarrow \mathcal{R}^m$, to jest zanurzenia zależnego tylko od pozycji tokena $i$.

Następnie takie zanurzenie $E^p$ jest po prostu dodawane do standardowego embeddingu „semantycznego” $E^s$, by ostatecznie otrzymać embeddingu tokena na konkretnej pozycji:

$$E(w_i) = E^p(i) + E^s(w_i).$$

Rzecz jasna rozmiar embeddingu pozycyjnego musi być identyczny z rozmiarem zwykłego embeddingu ($m$).

Wyuczalne embeddingi pozycyjne

Najprostszy sposób definiowania funkcji $E_p$ to po prostu przypisanie każdej pozycji (siłą rzeczy ze skończonego zbioru $\{1,\dots,p_{\operatorname{max}}\}$) osobnego wyuczalnego wektora. Przypomina to zwykły embedding wyuczalny dla poszczególnych słów: podobnie jak słowa _a, aby, abrakadabra,…,/żyzny/ mają swoje embeddingi, tak i pozycje będą miały swoje embeddingi (pozycja 1 ma swój embedding, pozycja 2 — inny itd., oczywiście nie należy tego mylić z embeddingami tokenów złożonych z cyfr _1, 2 itd.).

Zaletą tego podejścia jest prostota, wady to:

  • nie generalizuje się na sekwencje dowolnej długości
    • jeśli zdarzy się zdanie dłuższe niż $p_{\operatorname{max}}$, embeddingu po prostu… nie ma
    • … wprawdzie można po prostu zdefiniować cyklicznie embeddingi $E^p_{p{\operatorname{max}}+i} = E^p(i)$
    • … ma to jednak swoje wady (na pozycji $p{\operatorname{max}}+1$ sztucznie „wracamy” na początek tekstu,
  • sieć musi uczyć się osobno dla sąsiadujących pozycji, na przykład embedding pozycji 38 musi zostać wyuczony zupełnie niezależnie od pozycji 39,
  • … co jest szczególnie problematyczne dla pozycji o wyższych numerach pojawiających się rzadziej,
  • tego rodzaju embeddingów nie da się stosować relatywnie, a chcielibyśmy, żeby atencja z wyrazu na pozycji 14 kierowana na wyraz 12 była w pewnym stopniu podobna do atencji z wyrazu 27 kierowanej na wyraz 25.

Embeddingi sinusoidalne

Byłoby pożądane, gdyby za pomocą embeddingów pozycyjnych możliwe było wyrażenie pozycji wyrazów w różnych skalach — zarówno bezpośrednio poprzedzanie/następowanie, jak również relację typu „słowo X występuje około 8 słów wcześniej od słowa Y” (i kontrastowania z relacją typu „słowo X występuje około 15 słów wcześniej od słowa Y”).

Analogie z reprezentacją czasu

Co ciekawe, istnieją analogie między sposobami, w jakie można reprezentować pozycji i technikami reprezentacji czasu, wypracowywanymi przez ludzkość (od setek lat!).

Przede wszystkim, jesteśmy przyzwyczajeni do reprezentacji czasu, z natury ciągłego, za pomocą serii coraz dłuższych cykli (dotyczy to nie tylko kultury zachodniej, lecz także na przykład cywilizacji Majów!).

Na przykład znacznik czasowy dla konkretnej chwili w czasie może mieć postać: 2022-06-13 09:11:23.12, co można by reprezentować za pomocą 7-elementowego wektora:

$$[2022,06,13,09,11,23,12].$$

Dla spójności elementy wektora można by znormalizować względem czasu trwania danego cyklu, otrzymamy wówczas (załóżmy, że dla lat nadrzędnym cyklem są stulecia):

$$[0.220,0.500,0.433,0.375,0.183,0.383,0.120]$$

(np. dla godzin $9/24=0,375$).

Analogowa reprezentacja czasu

Zauważmy, że powyższa reprezentacja jest „cyfrowa”, nieciągła. Na przykład w sylwestra 2022 roku pierwsza pozycja nagle „przeskoczy” na 2023. Matematycznie oznacza to nieciągłość, która nie jest pożądana z punktu widzenia procesu propagacji wstecznej. Lepiej gdyby wszystkie pozycje wektora zmieniają się w sposób ciągły, analogowy, jak wskazówki zegara! W zwykłym bowiem zegarze analogowym wszystkie wskazówki się cały czas obracają, jedne szybciej, drugie wolniej. Na przykład 30 czerwca 2022 roku nie oznaczałby tak naprawdę roku 2022, tylko 2022,5.

A zatem właściwa reprezentacja wektorowa przykładowego momentu w czasie powinna mieć raczej postać:

def to_analog(tv):
    cycles = [100,12,30,24,60,60,100]
    analog_tv = []

    prev = 0.0

    for ix in range(len(tv)-1, -1, -1):
        v = tv[ix]/cycles[ix] + prev * (1 / cycles[ix])
        analog_tv.append(v)
        prev = v

    analog_tv = analog_tv[-1::-1]
    return analog_tv

tv = to_analog([22,6,13,9,11,23,12])
tv
[0.22537174740226337, 0.5371747402263375, 0.4460968827160494, 0.3829064814814815, 0.18975555555555554, 0.38533333333333336, 0.12]
Dodawanie czasu

Podstawowa operacja dotycząca czasu to przesunięcie momentu czasu o pewien okres (wprzód lub w tył), właściwie przypomina to zwykłe dodawanie czy odejmowanie, pojawia się jednak pewna trudność. Proste dodawanie wektorów nie zawsze da dobry wyniku, np. jeśli dodamy do naszego znacznika 20 godzin:

delta_v = to_analog([0,0,0,20,0,0,0])
delta_v
[2.3148148148148147e-05, 0.0023148148148148147, 0.02777777777777778, 0.8333333333333334, 0.0, 0.0, 0.0]
def vsum(a, b):
    return [ai+bi for ai, bi in zip(a, b)]

vsum(tv, delta_v)
[0.22539489555041153, 0.5394895550411523, 0.4738746604938272, 1.2162398148148148, 0.18975555555555554, 0.38533333333333336, 0.12]

Problem polega na tym, że na czwartej pozycji pojawiła się wartość większa od 1. Powinniśmy zostawić tylko część ułamkową (0.2162398148148148). Możemy tak uczynić, ale wtedy oznacza to nieciągłość między 0.99 a 1.00 (czyli 0.00 bo obcięciu do części ułamkowej). Znowu powracamy do problemu nieciągłości po wypełnieniu cyklu (tak naprawdę „rozwiązaliśmy” go tylko dla najdłuższego cyklu). Gdybyśmy mogli tylko utożsamić wartość 1 z 0… podobnie jak $2\pi$ z 0 dla funkcji trygonometrycznych. Albo gdyby reprezentować czas dosłownie za pomocą wskazówek, które po wypełnieniu cyklu w ciągły sposób powracają do pozycji początkowej…

Czas reprezentowany przez wskazówki zegara

Pozycja wskazówki jest reprezentowana w sposób jednoznaczny przez współrzędne końca, dla każdego cyklu możemy wprowadzić dwie wartości (znormalizowane) względem długości wskazówki. To znaczy, gdyby przyjąć, że długość wskazówki wynosi 1, wówczas współrzędne jej końca mają wartość:

$$x = \cos\phi, y = \sin\phi,$$

gdzie $phi$ jest kątem wskazówki.

W ten sposób otrzymamy dwa razy dłuższy wektor:

import math

def to_hand_coord(atv):
    out = []
    for v in atv:
        phi = v / 2*math.pi
        out.append(math.cos(phi))
        out.append(math.sin(phi))

    return out

to_hand_coord(tv)
[0.9379890645739346, 0.34666484497236694, 0.6646342658032391, 0.7471688515457462, 0.7643734166510766, 0.6447738207442666, 0.8245057772081804, 0.5658535352459454, 0.9559058477167625, 0.2936733053937619, 0.8223427069797843, 0.5689925063453478, 0.9822872507286887, 0.1873813145857246]
Obrót jako mnożenie macierzy

Okazuje się, że obrót w przestrzeni euklidesowej można wyrazić za pomocą mnożenia macierzy, na przykład, aby obliczyć współrzędne na płaszczyźnie po obrocie o 90 stopnia, wystarczy przemnożyć wektor współrzędnych przez macierz:

\begin{matrix} 0 & -1 \\ 1 & 0 \\ \end{matrix}

W ogólnym przypadku obrót o kąt $\phi$ oznacza przemnożenie przez macierz:

\begin{matrix} \cos\phi & -\sin\phi \\ \sin\phi & \cos\phi \\ \end{matrix}

Jest to bardzo dobra wiadomość! Okazuje się, że „przemieszczanie” się w czasie można zrealizować za pomocą mnożenia macierzy — operacji, do której stworzono karty graficzne!

Reprezentacja pozycji tokenów

Analogicznie do czasu, można reprezentować pozycję wyrazów. Zamiast naturalnych cykli lat, miesięcy, dni, itd. musimy tylko narzucić z góry cykle. Jeśli rozmiar embeddingu wynosi $m$, musimy wprowadzić $m/2$ cykli (dlaczego?).

Można na przykład wprowadzić narastające geometrycznie cykle o długości od 1 do 10000 (10000 to arbitralnie wybrana liczba), tj. $k$-ty cykl będzie miał długość $10000^{2k/m}$, wtedy dla parzystej pozycji wektora zanurzeń:

$$E_{p(i)_2k} = \sin(\frac{i}{10000^{2k/m}}),$$

dla nieparzystej zaś:

$$E_{p(i)_{2k+1}} = \sin(\frac{i}{10000^{2k/m}}),$$

Zanurzenia względne

Inne podejście polega na skupieniu się na tym, by Transformer był w stanie operować na względnych pozycjach. W tym podejściu informacja o pozycji nie jest dodawana do zanurzeń, lecz jest „wstrzykiwana” do atencji jako pozycyjne obciążenie (_positional bias), tj. np. w modelu T5 model uczy się serii skalarnych obciążeń $b_{\Delta}$, gdzie $Δ ∈ \\{-Δ\operatorname{max},…,-1,0,1,…,Δ\operatorname{max}\\}.

Obciążenia te po prostu są dodawane do atencji:

$$\hat{\alpha}_{i,j} = \vec{q_i}^T\vec{k_j} + b{j-i}.$$

Zalety takiego podejścia:

  • stosunkowo niski koszt obliczeniowy,
  • względne pozycje są uwzględniane nie tylko na początku obliczeń, lecz w każdej warstwie.