12. GPT-2
Zapomnijmy na chwilę o bibliotece NeuralNetworks i wróćmy do ręcznej implementacji sieci neuronowych, podobnie jak to ćwiczyliśmy w rozdziale 4.
Naszym celem będzie implementacja w C# modelu językowego GPT-2 (ang. Generative Pre-trained Transformer 2) opracowanego przez OpenAI. Oryginalny kod w języku Python, stanowiący ilustrację artykułu "Language Models are Unsupervised Multitask Learners", opublikowany został w 2019 roku i jest dostępny na GitHub.
Note
Fork oryginalnego repozytorium OpenAI z niezbędnymi do jego uruchomienia modyfikacjami znajduje się tutaj. Zmiany obejmują m.in. użycie modułu tensorflow.compat.v1 w związku z aktualizacją Tensorflow do wersji 2.x.
Implementować będziemy jedynie część wystarczającą do generowania tekstu (ang. inference) na podstawie zadanego promptu (tekstu początkowego). Pominiemy natomiast na razie proces trenowania modelu i posłużymy się gotowymi wagami udostępnionymi przez OpenAI.
12.1. Uruchomienie oryginalnego kodu GPT-2
Aby upewnić się, że nasza implementacja w C# będzie zgodna z oryginałem, najpierw uruchomimy oryginalny kod GPT-2 w Pythonie i sprawdzimy jego działanie.
W tym celu:
Uruchamiamy PowerShell
Przygotowujemy katalog roboczy:
cd \
md Gpt2Test
cd Gpt2Test
- Klonujemy repozytorium GPT-2 (wyżej wspomniany fork) i przechodzimy do katalogu z kodem źródłowym:
git clone https://github.com/kowaliszyn-pl/openai-gpt-2.git
cd openai-gpt-2
- Tworzymy i aktywujemy środowisko wirtualne Pythona:
python -m venv .venv
.\.venv\Scripts\Activate.ps1
- Instalujemy wymagane pakiety:
pip install -r requirements.txt
- Pobieramy model GPT-2 w wersji 124M (najmniejszy z dostępnych):
python download_model.py 124M
Note
Pliki modelu (checkpoint + hiperparametry + pliki tokenizatora) pobierane są przez skrypt download_model.py z https://openaipublic.blob.core.windows.net/gpt-2/124M i zapisywane w katalogu models\124M.
- Uruchamiamy skrypt generujący (deterministyczny) tekst na podstawie pobranego modelu:
python src/interactive_conditional_samples.py --top_k 1 --length 32
- Wpisujemy prompt:
Poland is a
Po chwili powinniśmy zobaczyć coś podobnego do poniższego ekranu:

Rysunek 12.1. Wynik uruchomienia oryginalnego kodu GPT-2 w Pythonie
Na podstawie promptu "Poland is a" model wygenerował dalszą część tekstu:
" country that has been a beacon of democracy for decades.
The country's economy is booming, with the country's GDP growing at a record pace.".
i ten sam wynik będziemy się starać uzyskać w naszej implementacji w C#.
12.2. Tokenizator
Model GPT-2 nie operuje bezpośrednio na znakach czy słowach, lecz na tokenach (ang. tokens), które są jednostkami tekstu o zmiennej długości z przypisanymi identyfikatorami liczbowymi. Tokeny mogą reprezentować pojedyncze znaki, części słów lub całe słowa, w zależności od ich częstotliwości występowania w korpusie treningowym. Tokenizacja tekstu polega na podziale ciągu znaków na tokeny zgodnie z określonym słownikiem (ang. vocabulary).
Wspomniany wyżej prompt "Poland is a" zostanie w przypadku oryginalnego modelu GPT-2 podzielony na tokeny w następujący sposób (por. rys. 12.1):
| Token | Identyfikator liczbowy |
|---|---|
| "Pol" | 8017 |
| "and" | 392 |
| " is" | 318 |
| " a" | 257 |
Tabela 12.1. Tokeny i ich identyfikatory dla promptu "Poland is a"
Dlaczego słowo "Poland" nie zostało potraktowane jako jeden token? Ponieważ w korpusie treningowym GPT-2 słowo to występowało zbyt rzadko, aby zostało uwzględnione w słowniku jako pojedynczy token (liczba tokenów jest ograniczona do 50 257, włączając w to znacznik końca tekstu). W rezultacie słowo "Poland" zostało podzielone na dwa tokeny: "Pol" i "and".
12.2.1. Proces tokenizacji
Za proces tokenizacji odpowiada metoda Encode w klasie GPT2Tokenizer, której implementacja w C# wygląda następująco:
public int[] Encode(string text)
{
List<int> tokenIds = [];
IEnumerable<string> words = GetWords(text);
foreach (string word in words)
{
// Encode word to custom UTF-8-based byte representation. Word " is" will be encoded to "Ġis".
string encodedWord = EncodeUtf8(word);
// Word "Poland" will be split to tokens "Pol", "and" and returned as "Pol and"
string tokenTextsAsString = GetTokenTexts(encodedWord);
// Split token texts by space. Word "Pol and" will be split to token texts: ["Pol", "and"]
string[] tokenTexts = tokenTextsAsString.Split(SingleSpace, StringSplitOptions.RemoveEmptyEntries);
foreach (string tokenText in tokenTexts)
{
if (!_textToTokenId.TryGetValue(tokenText, out int tokenId))
{
throw new InvalidOperationException($"Token '{tokenText}' not present in vocabulary.");
}
tokenIds.Add(tokenId);
}
}
return [.. tokenIds];
}
protected virtual IEnumerable<string> GetWords(string text)
// Split text into words using the word pattern
=> _wordPattern.Matches(text).Select(m => m.Value);
Listing 12.1. Implementacja metody Encode tokenizatora
Note
Powyższy kod w pełnej wersji znajduje się na GitHub.
12.2.1.1. Podział na słowa
Tekst przeznaczony do tokenizacji - przekazywany w argumencie text powyższej metody - jest w pierwszej kolejności dzielony na poszczególne słowa (metoda GetWords). Kod tokenizatora GPT-2 wykorzystuje do tego celu wyrażenie regularne (_wordPattern):
's|'t|'re|'ve|'m|'ll|'d| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+
Tak więc następujące słowa będą wyodrębniane z tekstu:
- Angielskie contractions (np. "'s", "'t", "'re", "'ve", "'m", "'ll", "'d")
- Słowa złożone tylko z liter (oznaczone jako
\p{L}+) wraz z ewentualną poprzedzającą spacją (oznaczoną jako?), np. "Poland", " is" - Słowa złożone tylko z cyfr (oznaczone jako
\p{N}+) wraz z ewentualną poprzedzającą spacją, np. "42", " 2026" - Inne znaki (oznaczone jako
[^\s\p{L}\p{N}]+) wraz z ewentualną poprzedzającą spacją, np. "://" w adresie URL - Grupy spacji (oznaczone jako
\s+(?!\S)), użyteczne do zachowania informacji o odstępach przed słowami - Spacje (oznaczone jako
\s+)
Z tekstu "Poland is a" zostaną więc wyodrębnione trzy słowa: "Poland", " is" (z poprzedzającą spacją) oraz " a" (również ze spacją). Będą one po kolei przypisywane do zmiennej word w pętli foreach.
12.2.1.2. Kodowanie słowa
Drugim krokiem jest zakodowanie wyodrębnionego słowa do specjalnej reprezentacji opartej na kodowaniu UTF-8. W tym celu wykorzystywana jest metoda EncodeUtf8, której implementacja wygląda następująco:
/// <summary>
/// Encodes the specified string into a custom UTF-8-based encoded representation.
/// </summary>
private string EncodeUtf8(string word)
{
byte[] utf8Bytes = _utf8Encoding.GetBytes(word);
StringBuilder encoded = new(word.Length);
foreach (byte utf8Byte in utf8Bytes)
{
encoded.Append(_byteToChar[utf8Byte]);
}
return encoded.ToString();
}
Listing 12.2. Implementacja metody EncodeUtf8 tokenizatora
Przekazywane w argumencie word słowo jest konwertowane na tablicę bajtów w kodowaniu UTF-8. Na przykład słowo " is" zostanie zakodowane w postaci tablicy bajtów utf8Bytes = [32, 105, 115] (gdzie 32 to kod spacji, 105 to kod litery 'i', a 115 to kod litery 's'). Następnie każdy taki bajt jest zamieniany na odpowiadający mu w słowniku Dictionary<byte, string> _byteToChar znak. W zakresie interesujących nas bajtów zawartość tego słownika jest następująca:
| Bajt | Znak |
|---|---|
| 32 | "Ġ" |
| 105 | "i" |
| 115 | "s" |
Tabela 12.2. Mapowanie bajtów na znaki dla tokenizatora GPT-2. Zawartość słownika _byteToChar
W rezultacie słowo " is" zostanie zakodowane jako "Ġis".
Weźmy inny przykład. Słowo "Żółć" zostanie zakodowane za pomocą metody UTF8Encoding.GetBytes do 8-elementowej tablicy bajtów [197, 187, 195, 179, 197, 130, 196, 135]. Następnie każdy bajt zostanie zamieniony na odpowiadający mu znak wg poniższej tabeli:
| Bajt | Znak |
|---|---|
| 197 | "Å" |
| 187 | "»" |
| 195 | "Ã" |
| 179 | "³" |
| 130 | "Ĥ" |
| 196 | "Ä" |
| 135 | "ĩ" |
Tabela 12.3. Mapowanie bajtów na znaki dla tokenizatora GPT-2 (przykład dla słowa "Żółć")
W rezultacie słowo "Żółć" zostanie przez metodę EncodeUtf8 zakodowane jako "ŻóÅĤÄĩ".
Osiągnięto w ten sposób możliwość zamiany dowolnego słowa (dowolnie kodowanego) na ciąg znaków, z których każdy reprezentuje jeden bajt oryginalnego słowa. Bajt ten jest zamieniany na odpowiadający mu znak w słowniku _byteToChar. I tyle.
Jak zbudowano słownik _byteToChar? Za jego utworzenie odpowiedzialna jest metoda CreateByteCharMapping, która:
- do pierwszych 188 bajtów przypisuje wybrane, czytelne znaki ze zbioru Latin-1 (https://pl.wikipedia.org/wiki/ISO_8859-1): od '!' do '~', od '¡' do '¬' oraz od '®' do 'ÿ'
- dla pozostałych bajtów przypisuje znaki z zakresu Unicode od U+0100 (256) wzwyż (np. 'Ā', 'ā', 'Ă', 'ă', 'Ą', 'ą' itd.), aż do uzyskania unikalnych znaków dla wszystkich 256 możliwych wartości bajtów (0-255).
12.2.1.3. Podział słowa na tokeny
Kolejnym krokiem jest podział zakodowanego słowa na tokeny. W tym celu wykorzystywana jest metoda GetTokenTexts, której implementacja wygląda następująco:
private string GetTokenTexts(string word)
{
// Check cache first to avoid redundant computations for the same word
if (_cache.TryGetValue(word, out string? cached))
{
return cached;
}
// Split word into list of characters (initially each character is a separate token)
List<string> wordParts = [.. word.Select(static c => c.ToString())];
// Get all adjacent pairs in the current word representation
HashSet<(string, string)> wordPairs = GetPairs(wordParts);
// Iteratively merge the most frequent pair until no more merges are possible
while (wordPairs.Count > 0)
{
(string first, string second) = wordPairs
.OrderBy(pair => _pairRanks.TryGetValue(pair, out int value) ? value : int.MaxValue)
.First();
if (!_pairRanks.ContainsKey((first, second)))
{
break;
}
List<string> mergedWordParts = [];
int wordPartIndex = 0;
while (wordPartIndex < wordParts.Count)
{
int firstPartFromPairIndex = wordParts.FindIndex(wordPartIndex, s => s == first);
// We have the following 4 cases to consider when merging pairs:
// 1. No more occurrences of the first part of the pair - we can add all remaining parts and break
if (firstPartFromPairIndex == -1)
{
mergedWordParts.AddRange(wordParts.GetRange(wordPartIndex, wordParts.Count - wordPartIndex));
break;
}
// 2. There are some parts before the first part of the pair - we can add them all before merging
if (firstPartFromPairIndex > wordPartIndex)
{
mergedWordParts.AddRange(wordParts.GetRange(wordPartIndex, firstPartFromPairIndex - wordPartIndex));
wordPartIndex = firstPartFromPairIndex;
}
// 3. We found the first part of the pair and it is followed by the second part - we can merge them and skip both parts
if (wordPartIndex < wordParts.Count - 1 && wordParts[wordPartIndex] == first && wordParts[wordPartIndex + 1] == second)
{
mergedWordParts.Add(first + second);
wordPartIndex += 2;
}
// 4. We found the first part of the pair but it is not followed by the second part - we can add the first part and continue searching for the next occurrence
else
{
mergedWordParts.Add(wordParts[wordPartIndex]);
wordPartIndex += 1;
}
}
wordParts = mergedWordParts;
if (wordParts.Count == 1)
{
break;
}
wordPairs = GetPairs(wordParts);
}
string result = string.Join(SingleSpace, wordParts);
_cache[word] = result;
return result;
}
Listing 12.3. Implementacja metody GetTokenTexts tokenizatora
Metoda ta implementuje algorytm Byte Pair Encoding (BPE), który polega na iteracyjnym łączeniu najczęściej występujących par znaków w słowie, aż do momentu, gdy nie będzie można już znaleźć pary występującej w słowniku par tokenów (_pairRanks).
Weźmy ponownie przykład słowa "Poland", które zostało zakodowane jako "Poland" (bez zmian, ponieważ składa się z czytelnych znaków Latin-1). Początkowo słowo to jest reprezentowane jako lista znaków: wordParts = ["P", "o", "l", "a", "n", "d"]. Następnie, za pomocą metody GetPairs tworzone są wszystkie możliwe pary znaków (bigramy, ang. bigrams): ("P","o"), ("o","l"), ("l","a"), ("a","n"), ("n","d"). Spośród tych par wybierana jest ta, która występuje najczęściej w słowniku _pairRanks. Dla naszego tokenizatora jest to akurat para ("a","n"). Zostaje ona połączona w jeden token "an", a słowo jest aktualizowane do postaci: mergedWordParts = ["P", "o", "l", "an", "d"]. Następnie podmieniamy wordParts na mergedWordParts. Proces ten jest powtarzany aż do momentu, gdy nie będzie można już znaleźć żadnej pary występującej w słowniku (nie ma w słowniku pary ("Pol","and")). W rezultacie słowo "Poland" zostanie podzielone na tokeny "Pol" i "and".
Ostatecznie metoda GetTokenTexts zwraca ciąg tokenów oddzielonych spacjami, np. "Pol and" dla słowa "Poland" lub "Ġis" dla słowa " is" (to również wyjaśnia dlaczego znak "Ġ" użyty został do reprezentowania spacji, a nie sama spacja; spacja jest nam później potrzebna do innych celów 😈). Ciąg ten dzielony jest w miejscu występowania spacji na poszczególne teksty tokenów ("Pol", "and", "Ġis", "Ġa"), które z kolei są mapowane na identyfikatory liczbowe za pomocą słownika _textToTokenId w metodzie Encode, jak pokazano na listingu 12.1. Dla przykładu, wartość _textToTokenId["Pol"] wynosi 8017 i taki też identyfikator liczbowy zostanie zwrócony jako pierwszy token (tablica 12.1.).
Skąd biorą się definicje par w słowniku _pairRanks? Są one wczytywane z pliku vocab.bpe, który jest częścią definicji tokenizatora w modelu GPT-2. Plik ten zawiera listę par tokenów wraz z ich rangami (względnymi częstotliwościami występowania) w korpusie treningowym.
12.3. Predykcja następnego tokenu w postaci logitów (inferencja)
Prompt, przekształcony zgodnie z powyższym opisem do postaci listy identyfikatorów cyfrowych (np. inputIds = [8017, 392, 318, 257]), przekazywany jest do modelu GPT-2, który w ramach przebiegu w przód (metoda float[,] Forward(int[] inputIds, Gpt2Params modelParams, int headCount)) oblicza logity predykcji następnego tokenu.
Logity nie są prawdopodobieństwem w sensie ścisłym, lecz wartościami liczbowymi, które po przekształceniu funkcją Softmax dają rozkład prawdopodobieństwa wystąpienia poszczególnych tokenów jako następnych w sekwencji. Im wyższy logit dla danego tokenu, tym większe prawdopodobieństwo, że ten token zostanie wybrany jako następny.
Funkcja Softmax ma tę właściwość, że przekształca dowolne wartości liczbowe (logity) na wartości z zakresu (0, 1), które sumują się do 1, co w cudowny sposób pozwala interpretować je jako prawdopodobieństwa (stuprocentowa pewność = 1). W przypadku modelu GPT-2, logity są obliczane dla każdego tokenu w słowniku (50 257 tokenów dla wersji 124M), a następnie funkcja Softmax jest stosowana do tych logitów, aby uzyskać rozkład prawdopodobieństwa dla każdego tokenu jako następnego w sekwencji.
Metoda Forward zaimplentowana jest następująco:
private static float[,] Forward(int[] inputIds, Gpt2Params modelParams, int headCount)
{
// [inputTokens, embeddingSize]
float[,] inputTokenEmbeddings = EmbedTokens(inputIds, modelParams.TokenEmbeddings, modelParams.PositionalEmbeddings);
for (int blockIndex = 0; blockIndex < modelParams.Blocks.Length; blockIndex++)
{
Gpt2Block block = modelParams.Blocks[blockIndex];
// [inputTokens, embeddingSize]
inputTokenEmbeddings = TransformerBlockForward(inputTokenEmbeddings, block, headCount);
}
// Final layer norm: [inputTokens, embeddingSize]
inputTokenEmbeddings = LayerNormForward(inputTokenEmbeddings, modelParams.FinalLayerNorm);
// Project to vocab: [inputTokens, embeddingSize] -> [inputTokens, vocabularySize]
float[,] logitsMatrix = inputTokenEmbeddings.MultiplyDot(modelParams.TokenEmbeddings.Transpose());
// [inputTokens, vocabularySize]
return logitsMatrix;
}
Listing 12.4. Implementacja metody Forward modelu GPT-2
Kolejne kroki tej metody to:
EmbedTokens: Zamiana identyfikatorów tokenów na ich reprezentacje wektorowe (nazywane embeddingami albo osadzeniami) oraz dodanie embeddingów pozycyjnych, co pozwala modelowi uwzględnić kolejność tokenów w sekwencji.TransformerBlockForward: Przetwarzanie sekwencji tokenów przez kolejne bloki transformera, które składają się z mechanizmu uwagi (attention) oraz warstw feed-forward.LayerNormForward: Zastosowanie normalizacji warstwowej (layer normalization) do uzyskanych reprezentacji tokenów.Projekcja końcowych reprezentacji tokenów [
inputTokens,embeddingSize] na przestrzeń słownika [inputTokens,vocabularySize], co daje logity dla każdego tokenu w słowniku.
Ostatecznie metoda Forward zwraca macierz logitów o wymiarach [inputTokens, vocabularySize], gdzie inputTokens to liczba tokenów w sekwencji wejściowej, a vocabularySize to liczba tokenów w słowniku modelu GPT-2 (50 257 dla wersji 124M).
Omówimy po kolei każdy z tych kroków.
12.3.1. Zamiana tokenów na embeddingi
Pierwszym krokiem w metodzie Forward jest zamiana identyfikatorów tokenów na ich reprezentacje wektorowe (embeddingi) oraz dodanie embeddingów pozycyjnych. Odbywa się to za pomocą metody EmbedTokens, której implementacja została przedstawiona poniżej:
private static float[,] EmbedTokens(int[] inputTokenIds, float[,] tokenEmbeddings, float[,] positionalEmbeddings)
{
// tokenEmbeddings are of size [vocabularySize, embeddingSize],
// where embeddingSize is a size of the model embeddings (for GPT-2 124M it is 768)
// and vocabularySize is the size of the vocabulary (for GPT-2 124M it is 50257)
// positionalEmbeddings are of size [contextSize, embeddingSize],
// where contextSize is the maximum context size of the model (for GPT-2 124M it is 1024)
int inputTokens = inputTokenIds.Length;
int embeddingSize = tokenEmbeddings.GetLength(1);
float[,] result = new float[inputTokens, embeddingSize];
for (int positionInInputSequence = 0; positionInInputSequence < inputTokens; positionInInputSequence++)
{
int tokenId = inputTokenIds[positionInInputSequence];
Debug.Assert(tokenId > 0 && tokenId < tokenEmbeddings.GetLength(0), $"Token id {tokenId} is outside the vocabulary range.");
// The purpose of this loop is to add token embeddings (for a given token) and positional embeddings (for a given position in the input sequence)
for (int embeddingIndex = 0; embeddingIndex < embeddingSize; embeddingIndex++)
{
// For each position in the input sequence, we get the token embedding and add the positional embedding
// embeddingIndex goes from 0 to 767 (for GPT-2 124M)
float value = tokenEmbeddings[tokenId, embeddingIndex];
value += positionalEmbeddings[positionInInputSequence, embeddingIndex];
result[positionInInputSequence, embeddingIndex] = value;
}
}
return result;
}
Listing 12.5. Implementacja metody EmbedTokens modelu GPT-2
Metoda ta działa na zasadzie lookupu. Trzymając się naszych danych przykładowych, z macierzy tokenEmbeddings wybierane są następujące wiersze: 8017, 392, 318, 257 (identyfikatory liczbowe tokenów), po czym dodawane są do nich wiersze z tabeli positionalEmbeddings o indeksach: 0, 1, 2, 3 (pozycje tokenów w sekwencji). Wynikiem jest macierz reprezentacji wektorowych tokenów o rozmiarze [4, 768].
12.3.2. Przetwarzanie przez bloki transformera
W przypadku wersji GPT-2 124M mamy do czynienia z 12 blokami transformera (ang. transformer blocks). Każdy blok składa się z mechanizmu uwagi (attention) oraz warstw feed-forward. W metodzie Forward (listing 12.4.) przetwarzanie przez kolejne bloki odbywa się wywołując w pętli metodę TransformerBlockForward, o następującej implementacji:
private static float[,] TransformerBlockForward(float[,] inputTokenEmbeddings, Gpt2Block block, int headCount)
{
// Arrays: inputTokenEmbeddings, normalizedEmbeddingsForAttention, attentionOutput, normalizedEmbeddingsForFeedForward, feedForwardOutput are all of size [inputTokens, embeddingSize].
// Multi-head causal self attention
float[,] normalizedEmbeddingsForAttention = LayerNormForward(inputTokenEmbeddings, block.LayerNorm1);
float[,] attentionOutput = MultiHeadAttention(normalizedEmbeddingsForAttention, block.Attention, headCount);
inputTokenEmbeddings = inputTokenEmbeddings.Add(attentionOutput);
// Position-wise feed forward network
float[,] normalizedEmbeddingsForFeedForward = LayerNormForward(inputTokenEmbeddings, block.LayerNorm2);
float[,] feedForwardOutput = FeedForwardNetwork(normalizedEmbeddingsForFeedForward, block.MultiLayerPerceptron);
inputTokenEmbeddings = inputTokenEmbeddings.Add(feedForwardOutput);
return inputTokenEmbeddings;
}
Listing 12.6. Implementacja metody TransformerBlockForward modelu GPT-2
W pierwszej kolejności następuje normalizacja warstwowa (ang. layer normalization) reprezentacji tokenów, a następnie przetwarzanie ich przez mechanizm uwagi (attention). Wynik tego przetwarzania jest dodawany do oryginalnych reprezentacji tokenów (tzw. residual connection - połączenia pomagające w przepływie gradientów poprzez zapobieganie ich zanikaniu). Następnie odbywa się kolejna normalizacja warstwowa, po której tokeny są przetwarzane przez warstwę feed-forward. Wynik tego przetwarzania jest ponownie dodawany do reprezentacji tokenów. Ostatecznie metoda TransformerBlockForward zwraca zaktualizowane reprezentacje tokenów, które następnie są przekazywane do kolejnego bloku transformera lub do końcowej projekcji na słownik.
12.3.2.1. Normalizacja warstwowa
Normalizacja warstwowa:
- polega na standaryzacji (średnia = 0, odchylenie standardowe = 1) wektora osadzeń dla każdego tokenu osobno
- jest stosowana przed mechanizmem uwagi oraz przed warstwą feed-forward w każdym bloku transformera
- jest mechanizmem wspomagającym stabilizację procesu trenowania sieci.
Dlaczego podkreślamy, że jest to normalizacja warstwowa? Ponieważ istnieje również inny rodzaj normalizacji, zwany normalizacją wsadową (ang. batch normalization), który jest stosowany w innych typach sieci neuronowych, np. w sieciach konwolucyjnych (CNN). Normalizacja warstwowa różni się od normalizacji wsadowej tym, że jest stosowana do każdego tokenu osobno, podczas gdy normalizacja wsadowa jest stosowana do całego batcha danych jednocześnie. Można powiedzieć, że normalizacja wsadowa (batchowa) jest "pionowa" (względem wszystkich próbek, ale dla jednej i tej samej cechy), a normalizacja warstwowa jest "pozioma" (względem wszystkich cech, ale dla jednej i tej samej próbki).
Implementacja naszej normalizacji, zawarta w metodzie LayerNormForward, wygląda następująco:
private static float[,] LayerNormForward(float[,] inputTokenEmbeddings, Gpt2LayerNormParams layerNorm)
{
// [embeddingSize]
float[] gamma = layerNorm.Gamma;
// [embeddingSize]
float[] beta = layerNorm.Beta;
float[,] normalized = inputTokenEmbeddings.StandardizeByRows();
float[,] res = normalized.MultiplyElementwise(gamma).AddRow(beta);
return res;
}
Listing 12.7. Implementacja metody LayerNormForward modelu GPT-2
Matematycznie moglibyśmy zapisać ją w postaci:
gdzie \(x\) to wektor osadzeń dla pojedynczego tokenu przed normalizacją, \(\bar{x}\) to wektor średnich z osadzeń dla każdego tokenu, \(\sigma\) to wektor odchyleń standardowych dla każdego tokenu, \(\gamma\) (gamma) to wektor skalowania (ang. scale), a \(\beta\) (beta) to wektor przesunięcia (ang. shift). Wywołanie operacji StandardizeByRows w powyższym kodzie wylicza \((x - \bar{x}) / \sigma\), po czym wynik jest skalowany przez \(\gamma\) i przesuwany przez \(\beta\).
Współczynniki \(\gamma\) i \(\beta\) są parametrami wektorowymi uczonymi podczas trenowania modelu. Każdy element wektora \(\gamma\) odpowiada za skalowanie jednej z 768 składowych wektora osadzeń, a każdy element wektora \(\beta\) odpowiada za przesunięcie jednej ze składowych wektora osadzeń.
12.3.2.2. Mechanizm uwagi
Mechanizm uwagi pozwala modelowi skupić się na różnych częściach sekwencji wejściowej podczas generowania kolejnych tokenów. W przypadku GPT-2 mamy do czynienia z mechanizmem uwagi o nazwie multi-head causal self attention.
Mechanizm uwagi możemy w C# zaimplementować w następujący sposób:
private static float[,] MultiHeadAttention(float[,] x, Gpt2MultiHeadAttentionParams attention, int headCount)
{
// For 124M: 3 * embeddingSize = 2304
// Projection weights: [embeddingSize, 3 * embeddingSize] ([768, 2304]), biases: [3 * embeddingSize]
Gpt2LinearParams Projection = attention.Projection;
// [inputTokens, embeddingSize] * [embeddingSize, 3 * embeddingSize] + [3 * embeddingSize] -> [inputTokens, 3 * embeddingSize]
x = LinearForward(x, Projection);
// Split into qkv: [inputTokens, 3 * embeddingSize] -> 3 * [inputTokens, embeddingSize]
(float[,] q, float[,] k, float[,] v) = SplitIntoQKV(x);
// Split qkv into heads: [inputTokens, embeddingSize] -> [headCount, inputTokens, headSize]
// where headSize = embeddingSize / headCount
// In GPT-2 124M, embeddingSize = 768, headCount = 12, so headSize = 64
// Each head is a separate attention mechanism that can focus on different aspects of the input sequence.
// For example, one head might focus on syntactic structure, while another might focus on semantic meaning.
// [headCount, inputTokens, headSize]
float[,,] qHeads = SplitHeads(q, headCount);
// [headCount, inputTokens, headSize]
float[,,] kHeads = SplitHeads(k, headCount);
// [headCount, inputTokens, headSize]
float[,,] vHeads = SplitHeads(v, headCount);
// Create a causal mask to prevent attention from looking at future tokens
int inputTokens = x.GetLength(0);
// [inputTokens, inputTokens]
float[,] causalMask = BuildCausalMask(inputTokens);
// Attention for each head
int headSize = qHeads.GetLength(2);
float[,,] outHeads = new float[headCount, inputTokens, headSize];
//Parallel.For(0, headCount, headIndex => // it does not speed up the execution
for (int headIndex = 0; headIndex < headCount; headIndex++)
{
// headIndex goes from 0 to 11 (for GPT-2 124M)
// [inputTokens, headSize]
float[,] qh = GetHead(qHeads, headIndex);
// [inputTokens, headSize]
float[,] kh = GetHead(kHeads, headIndex);
// [inputTokens, headSize]
float[,] vh = GetHead(vHeads, headIndex);
// [inputTokens, headSize]
float[,] attn = Attention(qh, kh, vh, causalMask);
for (int inputTokenIndex = 0; inputTokenIndex < inputTokens; inputTokenIndex++)
for (int headElementIndex = 0; headElementIndex < headSize; headElementIndex++)
outHeads[headIndex, inputTokenIndex, headElementIndex] = attn[inputTokenIndex, headElementIndex];
}
// Merge heads: [headCount, inputTokens, headSize] -> [inputTokens, embeddingSize]
float[,] mergedHeads = new float[inputTokens, headCount * headSize];
for (int inputTokenIndex = 0; inputTokenIndex < inputTokens; inputTokenIndex++)
{
for (int headIndex = 0; headIndex < headCount; headIndex++)
{
for (int headElementIndex = 0; headElementIndex < headSize; headElementIndex++)
{
mergedHeads[inputTokenIndex, headIndex * headSize + headElementIndex] = outHeads[headIndex, inputTokenIndex, headElementIndex];
}
}
}
// Out projection weights: [embeddingSize, embeddingSize], biases: [embeddingSize]
Gpt2LinearParams outputProjection = attention.OutputProjection;
// [inputTokens, embeddingSize] * [embeddingSize, embeddingSize] + [embeddingSize] -> [inputTokens, embeddingSize]
float[,] output = LinearForward(mergedHeads, outputProjection);
return output;
}
Listing 12.8. Implementacja metody MultiHeadAttention modelu GPT-2
Created: 2026-01-31
Last modified: 2026-02-07
Title: 12. GPT-2
Tags: [C#] [Sieci neuronowe] [GPT-2] [Python] [OpenAI]