Wnętrze transformera I: embeddingi, pozycje i self-attention
W poprzednim laboratorium obserwowaliśmy wejście i wyjście modelu. Teraz zajrzymy do jego wnętrza: zbudujemy uproszczoną, pojedynczą głowę samouwagi przyczynowej (causal self-attention) i prześledzimy wymiary kolejnych macierzy. Celem jest zrozumienie, jak informacje z wcześniejszych tokenów wpływają na reprezentację tokenu w danej pozycji.
Czego nauczysz się w tej części?
- odróżniać ID tokenu od przypisanego mu wektora embeddingu
- wyjaśniać, jak model może uwzględniać pozycję tokenu
- opisywać role zapytań (Q), kluczy (K) i wartości (V)
- wyznaczać wymiary macierzy QKᵀ
- obliczać wagi i wynik scaled dot-product attention
- stosować maskę przyczynową, aby dana pozycja nie korzystała z późniejszych tokenów
- sprawdzać, czy każdy wiersz wag sumuje się do 1 z dokładnością numeryczną
- wyjaśniać, dlaczego same wagi attention nie stanowią pełnego wyjaśnienia odpowiedzi modelu
Nie musisz jeszcze: znać wszystkich sposobów kodowania pozycji ani szczegółów architektury konkretnych modeli. W ćwiczeniu użyjemy prostego sposobu dodawania informacji o pozycji. Pracujemy na małych macierzach, których wartości można wyświetlić i sprawdzić ręcznie.
Jak będziemy pracować?
- Krok 1ID tokenu, embedding i pozycja
- Krok 2Q, K i V oraz ich wymiary
- Krok 3obliczanie wag i wyniku mechanizmu uwagi
- Krok 4maska przyczynowa
- Krok 5eksperyment z przeciekiem informacji z przyszłości
- Krok 6rezultat pracy
Zanim zaczniesz
Wybierz trzy tokeny i przypisz każdemu prosty wektor dwuwymiarowy. Następnie ręcznie oblicz iloczyn skalarny dwóch takich wektorów. W dalszej części wykorzystasz tę samą operację do obliczania wyników dla par zapytanie-klucz.
Przy każdej operacji zapisuj kształty: X: [n, d_model], Q,K: [n, d_k], V: [n, d_v], QKᵀ: [n, n]. Jawne śledzenie wymiarów usuwa większość typowych nieporozumień.
Token ID nie niesie użytecznej geometrii
Identyfikator tokenu jest indeksem w słowniku. To, że token ma ID 900, nie znaczy, że jest dziewięć razy „większy” od tokenu o ID 100. Warstwa embeddingu działa jak tabela: dla każdego ID wybiera wiersz macierzy E. W modelu wartości tej macierzy są uczone podczas treningu.
Dla jednej sekwencji długości n otrzymujemy macierz X ∈ ℝ^(n × d_model). Każdy wiersz jest wektorem przypisanym do jednego ID tokenu. Parametr d_model określa wymiar przestrzeni wektorowej, w której reprezentujemy pojedynczy token. Jeśli d_model = 4, jego embedding jest wektorem o czterech współrzędnych, należącym do ℝ⁴. Kolumny macierzy są składowymi wektorów, ale nie mają z góry przypisanych znaczeń. Nie zakładamy więc, że jedna kolumna oznacza na przykład emocję, a inna liczbę mnogą.
W kodzie dodajemy wymiar batcha, czyli liczbę sekwencji przetwarzanych razem. Wynik ma wtedy kształt [B, n, d_model]. Tutaj B = 1. W dalszych wzorach będziemy pomijać wymiar batcha, aby skupić się na operacjach wewnątrz jednej sekwencji.
import torch
from torch import nn
torch.manual_seed(7)
vocab_size = 12
d_model = 4
embedding = nn.Embedding(vocab_size, d_model) # E: [12, 4]
input_ids = torch.tensor([[2, 5, 2, 9]]) # [B=1, n=4]
x = embedding(input_ids) # [1, 4, 4]
print("E:", embedding.weight.shape)
print("input_ids:", input_ids.shape)
print("x:", x.shape)
print("x[0] - jedna sekwencja:", x[0].shape)
print("x[0, 0] to E[2]:", torch.equal(x[0, 0], embedding.weight[2]))
print("powtórzone ID daje ten sam wektor:",
torch.equal(x[0, 0], x[0, 2]))
W tym przykładzie wartości E są losowo zainicjalizowane, ponieważ nie trenujemy warstwy embeddingu. Kod pokazuje wybieranie wierszy macierzy, a nie wyuczone podobieństwo między tokenami. Oba wystąpienia ID 2 dają ten sam wektor na tym etapie. Ich reprezentacje mogą się później różnić, gdy model uwzględni pozycję i kontekst.
Embedding tokenu i wektor reprezentujący cały fragment tekstu to różne obiekty. Tutaj otrzymujemy jeden wektor dla każdego ID w sekwencji. W przyszłych laboratoriach zobaczymy, jak uzyskać reprezentację całego fragmentu i wykorzystać ją do porównywania tekstów.
Skąd model bierze informację o pozycji?
Tabela embeddingów tokenów wybiera wektor na podstawie ID tokenu. Dwa wystąpienia tego samego ID dostają więc na tym etapie identyczny wektor. Aby w naszym przykładzie rozróżnić ich miejsca w sekwencji, użyjemy drugiej tabeli, indeksowanej numerem pozycji.
Tabela pozycji ma kształt [max_seq_len, d_model]: zawiera jeden wektor dla pozycji 0, kolejny dla pozycji 1 i tak dalej. Pierwszy wymiar określa, ile pozycji można w ten sposób bezpośrednio obsłużyć, a drugi jest długością każdego wektora pozycyjnego. To inny rozmiar niż tabela embeddingów tokenów, która ma po jednym wierszu dla każdego ID w słowniku.
Załóżmy, że tabela pozycji ma kształt [32, 4]. Jeśli wejście ma pięć tokenów, wybieramy z niej wiersze dla pozycji 0, 1, 2, 3, 4. Otrzymujemy macierz P o kształcie [5, 4] i dodajemy ją do macierzy embeddingów tokenów X, która także ma kształt [5, 4]. Pozostałe wiersze nadal są częścią tabeli; po prostu nie są potrzebne dla tej sekwencji.
Tutaj E_pos oznacza całą tabelę pozycji, a P tylko wiersze wybrane dla bieżącej sekwencji. Dla wejścia [2, 5, 2, 9] pierwsze wystąpienie ID 2 otrzyma wektor tokenu powiększony o wektor pozycji 0, a drugie – ten sam wektor tokenu powiększony o wektor pozycji 2.
# Korzystamy z input_ids, x, nn i d_model z poprzedniego fragmentu.
max_seq_len = 32
position_embedding = nn.Embedding(max_seq_len, d_model)
n = input_ids.shape[1] # w przykładzie n = 4
positions = torch.arange(n).unsqueeze(0) # [[0, 1, 2, 3]]
position_vectors = position_embedding(positions)
x_with_position = x + position_vectors
print("cała tabela pozycji:", position_embedding.weight.shape) # [32, 4]
print("wybrane pozycje:", positions)
print("P dla tej sekwencji:", position_vectors.shape) # [1, 4, 4]
print("X:", x.shape) # [1, 4, 4]
print("H = X + P:", x_with_position.shape) # [1, 4, 4]
print("przed dodaniem pozycji wektory ID 2 są identyczne:",
torch.equal(x[0, 0], x[0, 2]))
print("po dodaniu pozycji reprezentacje się różnią:",
not torch.allclose(x_with_position[0, 0], x_with_position[0, 2]))
Dodatkowa jedynka w kształtach X, P i H to wymiar batcha. Tabela position_embedding.weight ma nadal 32 wiersze, choć w tym wywołaniu korzystamy tylko z czterech. W naszym kodzie jej wartości są losowo zainicjalizowane i nie trenujemy ich. Gdy taka tabela jest trenowanym elementem modelu, jej wartości są aktualizowane podczas uczenia.
W tej prostej implementacji
max_seq_lenogranicza liczbę pozycji, dla których możemy odczytać wektor z tabeli. Nie każdy model ma jednak taką tabelę: informację o pozycji można też dodać za pomocą stałego kodowania sinusoidalnego lub mechanizmu rotacyjnego (RoPE). Osobną sprawą jest maska przyczynowa – określa ona, z których wcześniejszych pozycji wolno korzystać podczas obliczania uwagi (ang. attention).
Q, K i V: trzy projekcje tej samej reprezentacji
Po dodaniu informacji o pozycji otrzymujemy macierz H: zawiera ona po jednym wektorze na każdy token w sekwencji. Jeśli ten sam token występuje dwukrotnie, zajmuje dwie różne pozycje, więc reprezentujemy osobno każde z tych wystąpień. Z każdego wektora obliczamy trzy projekcje liniowe za pomocą macierzy W_Q, W_K i W_V.
Macierze W_Q, W_K i W_V są parametrami modelu uczonymi podczas treningu. Natomiast Q, K i V są wynikami obliczeń dla bieżącej sekwencji: gdy zmieni się wejście H, zmienią się także te trzy wyniki.
Query (Q), czyli zapytania, porównujemy z Key (K), czyli kluczami. Dla pozycji i obliczamy dopasowanie jej zapytania q_i do kluczy k_j z poszczególnych pozycji j. Value (V) zawiera wektory, które później połączymy w wyniku. Waga przypisana pozycji j określa, przez jaki współczynnik pomnożymy jej wektor v_j. Wielkość jego wkładu zależy także od wartości samego wektora.
Można więc myśleć o Q i K jako o części ustalającej, skąd pobrać więcej informacji, a o V jako o części określającej, jaka informacja zostanie przekazana. To pomocna intuicja, a nie dosłowne pytania, etykiety czy osobne bazy danych. W następnej części zobaczymy, jak wyniki dopasowania zamieniają się w wagi.
Wzory pomijają ewentualne wyrazy wolne projekcji. Zapytania i klucze muszą mieć tę samą długość d_k, aby można było obliczać ich iloczyny skalarne. Wektory wartości mogą mieć inną długość d_v.
| Obiekt | Kształt dla jednej sekwencji | Rola |
|---|---|---|
| X | n × d_model | embeddingi tokenów |
| P | n × d_model | wektory pozycji wybrane dla sekwencji |
| H = X + P | n × d_model | wejście do trzech projekcji |
| W_Q, W_K | d_model × d_k | parametry projekcji zapytań i kluczy |
| W_V | d_model × d_v | parametry projekcji wartości |
| Q, K | n × d_k | zapytania i klucze obliczone dla bieżącej sekwencji |
| V | n × d_v | wartości obliczone dla bieżącej sekwencji |
| QKᵀ | n × n | surowe wyniki dopasowania wszystkich par pozycji |
Sprawdź trzy projekcje w kodzie
Wykorzystujemy x_with_position z poprzedniej sekcji. Ma ono kształt [1, 4, 4]: jedną sekwencję, cztery pozycje i cztery składowe wektora. Wybieramy tę jedną sekwencję, aby pracować na macierzy H o kształcie [4, 4]. Macierze projekcji w przykładzie są losowo zainicjalizowane; nie wykonujemy tutaj treningu.
import torch
h = x_with_position[0] # [n=4, d_model=4]
n, d_model = h.shape
d_k, d_v = 2, 3
torch.manual_seed(11)
w_q = torch.randn(d_model, d_k) # [4, 2]
w_k = torch.randn(d_model, d_k) # [4, 2]
w_v = torch.randn(d_model, d_v) # [4, 3]
q = h @ w_q # [4, 2]
k = h @ w_k # [4, 2]
v = h @ w_v # [4, 3]
print("H:", tuple(h.shape))
print("W_Q, W_K, W_V:",
tuple(w_q.shape), tuple(w_k.shape), tuple(w_v.shape))
print("Q, K, V:",
tuple(q.shape), tuple(k.shape), tuple(v.shape))
print("QKᵀ:", tuple((q @ k.T).shape))
print("Pierwsza pozycja:")
print("q[0]:", q[0])
print("k[0]:", k[0])
print("v[0]:", v[0])
Zwróć uwagę, że dla tej samej pierwszej pozycji otrzymujemy osobne wektory q[0], k[0] i v[0]. Każdy powstał z tego samego h[0], lecz przy użyciu innej macierzy projekcji. Dwie pierwsze projekcje mają długość 2, a trzecia długość 3. W kolejnym ćwiczeniu uprościmy dane do Q = K = V = X, żeby łatwo prześledzić samo obliczenie attention.
W macierzy QKᵀ wiersz i odpowiada pozycji tokenu, dla którego obliczamy wynik, a kolumna j pozycji tokenu, z którego można pobrać informację. Na tym etapie macierz zawiera surowe wyniki dla wszystkich par (i, j). Dopiero maska przyczynowa zablokuje pozycje j > i. Spośród pozostałych pozycji nie wybieramy automatycznie jednej „najlepszej”: każda może wnieść część informacji do wyniku.
Przykład. Załóżmy, że sekwencja ma cztery tokeny na pozycjach 0, 1, 2, 3. Obliczając wynik dla tokenu na pozycji i = 2, model może uwzględnić tokeny z pozycji j = 0, 1, 2, ale nie z pozycji j = 3. Jeśli po zastosowaniu maski i funkcji softmax ich wagi wynoszą przykładowo 0,2, 0,5 i 0,3, otrzymamy ważoną sumę 0,2·v₀ + 0,5·v₁ + 0,3·v₂. Token z pozycji 3 ma w tym obliczeniu wagę równą zeru.
Tak jak wcześniej, tabela pomija wymiar batcha. Dla batcha o wielkości B otrzymalibyśmy Q, K: [B, n, d_k], V: [B, n, d_v] oraz QKᵀ: [B, n, n]. Opisujemy jedną głowę attention; przy wielu głowach każda ma własne parametry projekcji.
Mechanizm uwagi oparty na skalowanym iloczynie skalarnym
Mamy już macierze Q, K i V. Teraz dla każdej pozycji chcemy ustalić, w jakim stopniu uwzględnić informacje z innych pozycji. Służy do tego mechanizm uwagi (ang. attention), jego omawiana tutaj wersja nosi nazwę scaled dot-product attention: najpierw porównuje zapytania z kluczami za pomocą iloczynów skalarnych, a potem łączy wektory wartości.
Wzór można czytać jako trzy kolejne macierze: S = QKᵀ / √d_k zawiera oceny dopasowania, A = softmax(S) zawiera wagi, a Y = AV zawiera wektory wynikowe.
- Porównanie: mnożymy
QprzezKᵀ. Element(i, j)wyniku jest iloczynem skalarnym zapytaniaq_ii kluczak_j. Otrzymujemy macierz[n, n]: po jednym wierszu dla każdej pozycji, dla której liczymy wynik, i po jednej kolumnie dla każdej pozycji, z której można pobrać informację. - Skalowanie: każdy wynik dzielimy przez tę samą liczbę
√d_k, gdzied_kjest długością wektorówQiK. Macierz nadal ma kształt[n, n]. Jej wiersze nie muszą jeszcze sumować się do1: to wciąż oceny dopasowania, a nie wagi. - Wagi: do każdego wiersza ocen stosujemy osobno softmax. Otrzymujemy macierz
Ao kształcie[n, n]. ElementA[i, j]jest wagą pozycjijprzy obliczaniu wyniku dla pozycjii. Wagi w każdym wierszu są nieujemne i sumują się do1. - Wynik: mnożymy
Ao kształcie[n, n]przezVo kształcie[n, d_v]. OtrzymujemyYo kształcie[n, d_v]: jeden nowy wektor długościd_vdla każdej pozycji w sekwencji.
Co dokładnie oznacza ostatni krok? Dla ustalonej pozycji i bierzemy wagi A[i, 0], A[i, 1], …, A[i, n−1] i obliczamy ważoną sumę wektorów v₀, v₁, …, vₙ₋₁. Wynik ma d_v składowych. Każdą składową liczymy jako osobną sumę po pozycjach j, używając tych samych wag A[i, j]. W następnej sekcji maska przyczynowa sprawi, że w wyniku dla pozycji i pozostaną tylko wartości z pozycji j ≤ i.
Dlaczego dzielimy właśnie przez √d_k?
Iloczyn skalarny dodaje do siebie d_k iloczynów składowych wektorów. Gdy rośnie d_k, typowe wartości takich sum mogą mieć coraz większy rozrzut. Duże różnice między ocenami sprawiają, że softmax daje wagi bardzo bliskie 0 albo 1, co może utrudniać uczenie. Dzielenie przez √d_k utrzymuje oceny w bardziej porównywalnej skali dla różnych długości wektorów.
Skąd bierze się pierwiastek?
W uproszczonym modelu matematycznym zakładamy, że dodawane w iloczynie skalarnym składniki są niezależne, mają średnią 0 i wariancję 1. Wariancja sumy d_k takich składników wynosi wtedy d_k, a jej odchylenie standardowe wynosi √d_k. Dlatego dzielimy przez pierwiastek, a nie przez samo d_k. To uzasadnienie skali użytej we wzorze, nie gwarancja, że każde rzeczywiste zapytanie i klucz spełniają te założenia.
Jeśli pamiętasz temperaturę z poprzedniego laboratorium, skojarzenie jest trafne: softmax(z / T) i softmax((QKᵀ) / √d_k) w podobny sposób zmieniają różnice między liczbami przed softmax. Tutaj odpowiednikiem z są oceny dopasowania pozycji, a √d_k jest czynnikiem skalującym wynikającym z wymiaru kluczy. Nie jest to temperatura losowania następnego tokenu: mechanizm uwagi oblicza wagi deterministycznie.
Macierze ocen i wag mają po
n × nelementów. Jeśli długość sekwencji wzrośnie dwukrotnie, liczba ich elementów wzrośnie czterokrotnie. Przyn = 4096jedna taka macierz ma ponad 16 milionów wartości, czyli około 64 MiB przy zapisiefloat32. Tak działa pokazana niżej, prosta implementacja. Istnieją wydajniejsze sposoby obliczania tego samego wyniku bez przechowywania całej macierzy wag naraz.
Wagi mechanizmu uwagi określają udział poszczególnych pozycji w łączeniu wektorów V. Nie są prawdopodobieństwami następnych tokenów z całego słownika.
Zbuduj mechanizm uwagi na małej macierzy
W poprzednim ćwiczeniu zobaczyliśmy, jak powstają osobne projekcje Q, K i V. Teraz zaczynamy nowy, niezależny przykład z trzema prostymi wektorami. Przyjmujemy Q = K = V = toy_x, aby móc łatwo policzyć działanie mechanizmu uwagi ręcznie. Wartości i wymiary będą więc inne niż w ćwiczeniu z projekcjami.
import math
import torch
toy_x = torch.tensor([
[1.0, 0.0],
[0.0, 1.0],
[1.0, 1.0],
]) # [n=3, d=2]
q = k = v = toy_x
raw_scores = q @ k.T # [3, 3]
scores = raw_scores / math.sqrt(q.shape[-1]) # [3, 3]
weights = torch.softmax(scores, dim=-1) # [3, 3]
result = weights @ v # [3, 2]
torch.set_printoptions(precision=3, sci_mode=False)
print("oceny przed skalowaniem:\n", raw_scores)
print("oceny po skalowaniu:\n", scores)
print("wagi po softmax:\n", weights)
print("suma każdego wiersza wag:", weights.sum(dim=-1))
print("wektory wynikowe:\n", result)
Jak czytać wynik? Wiersz weights[1] zawiera trzy wagi użyte do obliczenia result[1]. Pierwsza mnoży cały wektor v[0], druga cały wektor v[1], a trzecia cały wektor v[2]. Ponieważ wektory V mają tu po dwie składowe, result[1] również ma dwie składowe. W tej wersji nie ma jeszcze maski przyczynowej, więc pozycja 1 może korzystać także z późniejszej pozycji 2.
- Policz ręcznie wartość
scores[0, 2]. Pamiętaj o podzieleniu iloczynu skalarnego przez√d_k. - Odczytaj
weights[1]i zapiszresult[1]jako sumę trzech ważonych wektorówV. - Porównaj
weights[0]z wynikiemtorch.softmax(raw_scores[0], dim=-1). Czy skalowanie zmieniło sumę wag, czy ich proporcje? - Zmień trzeci wektor na
[-1, -1]i ponownie uruchom obliczenia. Jak zmieniły się wagi przypisane trzeciej pozycji w pierwszych dwóch wierszach? - Sprawdź asercją, że każdy wiersz macierzy
weightssumuje się do1.
Rozwiązanie pierwszego punktu
Dla scores[0, 2] porównujemy q[0] = [1, 0] z k[2] = [1, 1]. Ich iloczyn skalarny wynosi 1·1 + 0·1 = 1. Ponieważ d_k = 2, po skalowaniu otrzymujemy 1/√2 ≈ 0,707. Wynik nadal nie jest wagą i nie musi mieścić się między 0 a 1. Waga powstaje dopiero po zastosowaniu softmax do całego wiersza ocen.
Maska przyczynowa: bez podglądania późniejszych tokenów
W modelu generującym tekst reprezentacja pozycji i może korzystać z tokenu na tej pozycji oraz z tokenów wcześniejszych. Nie może korzystać z tokenów na pozycjach j > i, bo podczas przewidywania następnego tokenu nie byłyby jeszcze znane. Jest to ważne także podczas uczenia: choć przetwarzamy wiele pozycji sekwencji równocześnie, obliczenie dla każdej z nich musi zachować tę samą zasadę.
Maskę nakładamy na macierz scores przed softmax. Wyniki odpowiadające przyszłym pozycjom zastępujemy przez −∞. Po softmax ich wagi wynoszą 0, a wagi dozwolonych pozycji wciąż sumują się do 1. To właśnie sprawia, że dla wiersza i w obliczeniu uczestniczą tylko kolumny j ≤ i.
Przykład: dla pozycji i = 1 można użyć wartości V₀ i V₁, ale nie V₂. Dla pozycji i = 0 jedyną dozwoloną wartością jest V₀, więc jej waga wyniesie 1.
# Kontynuacja poprzedniego przykładu: korzystamy z q, v i scores.
n = q.shape[0]
# True oznacza tutaj pozycję przyszłą, którą chcemy zablokować.
future = torch.triu(
torch.ones(n, n, dtype=torch.bool),
diagonal=1,
)
masked_scores = scores.masked_fill(future, float("-inf"))
causal_weights = torch.softmax(masked_scores, dim=-1)
causal_context = causal_weights @ v
print("pozycje zablokowane przez maskę:\n", future)
print("wyniki po nałożeniu maski:\n", masked_scores)
print("wagi causal:\n", causal_weights)
print("wynik attention:\n", causal_context)
assert torch.allclose(causal_weights.sum(dim=-1), torch.ones(n))
assert torch.all(causal_weights[future] == 0)
torch.triu(..., diagonal=1) pozostawia wartości True tylko nad główną przekątną, czyli tam, gdzie j > i. W naszej macierzy future oznaczają one pozycje do zablokowania. Instrukcja masked_fill zastępuje odpowiadające im oceny przez −∞. Ponieważ exp(−∞) = 0, po softmax te pozycje mają wagę 0; pozostałe wagi w wierszu sumują się do 1. Maska określa więc, z których pozycji wolno korzystać, a softmax rozdziela wagi między dozwolone pozycje.
Wykryj przeciek przyszłości
Porównaj dwie sekwencje z takim samym pierwszym wektorem, lecz różnymi późniejszymi wektorami. Z maską wynik dla pozycji 0 powinien być w obu przypadkach taki sam. Bez maski może się zmienić, ponieważ pierwszy wektor będzie wtedy łączony również z informacją z późniejszych pozycji.
def attention_output(x: torch.Tensor, causal: bool) -> torch.Tensor:
scores = x @ x.T / math.sqrt(x.shape[-1])
if causal:
n = x.shape[0]
future = torch.triu(
torch.ones(n, n, dtype=torch.bool, device=x.device),
diagonal=1,
)
scores = scores.masked_fill(future, float("-inf"))
weights = torch.softmax(scores, dim=-1)
return weights @ x
a = torch.tensor([[1., 0.], [0., 1.], [1., 1.]])
b = torch.tensor([[1., 0.], [8., 8.], [-7., 2.]])
causal_a = attention_output(a, causal=True)[0]
causal_b = attention_output(b, causal=True)[0]
unmasked_a = attention_output(a, causal=False)[0]
unmasked_b = attention_output(b, causal=False)[0]
print("z maską:", causal_a, causal_b)
print("bez maski:", unmasked_a, unmasked_b)
assert torch.allclose(causal_a, causal_b)
assert not torch.allclose(unmasked_a, unmasked_b)
W tym przykładzie wynik dla pozycji 0 z maską to zawsze [1, 0]: pierwsza pozycja może korzystać tylko z własnej wartości. Bez maski jej wynik zależy także od wektorów na pozycjach 1 i 2, co ujawnia przeciek informacji z przyszłości.
Co pokazują wagi uwagi, a czego nie wyjaśniają?
Mapa wag pozwala sprawdzić, jaki udział w wyniku dla pozycji i mają wektory V z poszczególnych pozycji j. Jest więc użyteczna do obejrzenia działania konkretnej głowy uwagi w konkretnej warstwie. Wysoka waga nie oznacza jednak automatycznie, że odpowiadający jej token miał największy wpływ na końcową odpowiedź modelu.
Waga mówi, jak mocno mnożymy wektor v_j, ale nie mówi jeszcze, co zawiera ten wektor. Dalszy wynik zależy również od innych pozycji, głów i warstw oraz od kolejnych przekształceń. Same wektory V są obliczane dla bieżącego wejścia za pomocą uczonej macierzy projekcji W_V.
Prosty przykład: jeśli pozycja otrzyma wagę 0,8, lecz jej wektor v_j jest bliski zeru, jej wkład w tę sumę ważoną również będzie mały. Sama liczba 0,8 nie wystarcza więc do oceny wielkości przekazanej informacji, tym bardziej do wyjaśnienia końcowej predykcji całego modelu.
Nie wyciągaj z pojedynczej heatmapy wniosku: „model przypisał temu słowu wysoką wagę, więc właśnie ono spowodowało odpowiedź”. Heatmapa pokazuje wagi w jednym etapie obliczeń. Sprawdzenie wpływu tokenu na predykcję wymaga dodatkowej analizy, na przykład porównania wyników po kontrolowanej zmianie wejścia.
Praca z AI: możesz poprosić o kod rysujący heatmapy wag przed nałożeniem maski i po jej nałożeniu. Sam sprawdź podpisy osi: wiersz
ioznacza pozycję, dla której liczymy wynik, a kolumnajpozycję, z której pobieramy informację. Wskaż pola wyzerowane przez maskę i wyjaśnij, dlaczego w naszej implementacji to wiersze, a nie kolumny, sumują się do1.
Rezultat po tej części
Oddaj kod oraz tabelę kształtów dla jednej sekwencji: X, P, H, W_Q, W_K, W_V, Q, K, V, macierzy ocen, macierzy wag i wektorów wynikowych. Zaznacz, że w liczbowym ćwiczeniu z uwagą celowo przyjęliśmy uproszczenie Q = K = V = X.
Dołącz również:
- macierz wag przed nałożeniem maski i po jej nałożeniu,
- asercję sprawdzającą, że wagi przyszłych pozycji wynoszą
0, - porównanie dwóch sekwencji pokazujące, że bez maski wynik pozycji
0może zależeć od późniejszych wektorów, - ręczne obliczenie jednego elementu macierzy
scores, - dwa zdania: co pokazuje mapa wag i czego sama nie dowodzi.
Podsumowanie
Co naprawdę warto zapamiętać?
- ID tokenu wskazuje wiersz macierzy embeddingów; sama wartość ID nie jest jego cechą liczbową.
- Model musi uwzględniać kolejność i ograniczenia przepływu informacji. W naszym przykładzie dodawaliśmy wektory pozycji, a maska przyczynowa określała, do których pozycji wolno się odwołać.
QiKsłużą do obliczania ocen dopasowania, a wektoryVsą łączone według uzyskanych wag.QKᵀma kształt[n, n]: wiersze odpowiadają zapytaniom, a kolumny kluczom.- Dzielenie ocen przez
√d_kogranicza wzrost ich typowej skali. Dopiero softmax zamienia je w wagi, osobno normalizując każdy wiersz. - Po pomnożeniu wag
[n, n]przezVo kształcie[n, d_v]otrzymujemy po jednym wektorze długościd_vdla każdej pozycji. - Maska przyczynowa zeruje po softmax wagi pozycji
j > i. Pojedyncza mapa wag nie wyjaśnia całej predykcji modelu.
Przy każdej operacji zapisuj kształty macierzy. Sprawdzaj też, czy oglądasz surowe oceny dopasowania, wagi po softmax, czy wektory powstałe po pomnożeniu wag przez
V.
Co możesz sprawdzić w dokumentacji lub z pomocą AI?
- czym dodawane wektory pozycji różnią się od RoPE lub ALiBi,
- jak zoptymalizowane implementacje obliczają uwagę dla długich sekwencji,
- jak biblioteka układa wymiary batcha, pozycji i głów uwagi,
- jak zwizualizować wagi kilku głów tego samego modelu.
Pytania kontrolne
- Dlaczego ID tokenu nie powinno być bezpośrednio traktowane jako cecha liczbowa?
- Czym różni się dodanie wektora pozycji od zastosowania maski przyczynowej?
- Jaki kształt ma
QKᵀdla sekwencji długościn? - Dlaczego oceny dopasowania dzielimy przez
√d_k? - Wzdłuż którego wymiaru stosujemy softmax i dlaczego?
- W którym momencie nakładamy maskę przyczynową?
- Dlaczego wysoka waga uwagi nie jest sama w sobie dowodem, że dany token spowodował predykcję?