Strona główna / Artykuły

Tokenizer BPE od zera: krótsze sekwencje dla małego okna kontekstowego GPT

Napisz w Pythonie tokenizer do kodowania par bajtowych, przeszkol go na WikiText-2, zapisz wyniki fuzji w formacie JSON i sprawdź, w jaki sposób tokenizacja podsłówkowa rozszerza okno kontekstowe GPT.

Wydawca: REACTAPP.TOP26 min czytania6015 słów

Źródła: Tutorial 16: Beyond Character Tokens, Building a BPE Tokenizer

Tokery na poziomie znaków to najprostszy sposób na uruchomienie małego modelu GPT: słownictwo jest bardzo małe, nic nie jest nieznane, a każdy krok jest przejrzysty. Kosztem jest długość sekwencji, ponieważ każda litera zajmuje miejsce w oknie kontekstowym. Ten przewodnik zastępuje go kodowaniem par bajtowych (BPE), napisanym od zera w czystym Pythonie. Nauczysz go na danych WikiText-2, zapisazesz nabyte słownictwo oraz fuzje w formacie JSON, stworzysz ich „odcisk palca”, ponownie zakodujesz zbiór danych i zobaczysz dokładnie, co zmienia się podczas ponownego trenowania modelu przy użyciu tokenów podsłówkowych.

Kod zakłada istniejący mały model GPT w PyTorch oraz wyczyszczone pliki WikiText-2. Jeśli trening nadal nie działa poprawnie, najpierw należy go ustabilizować; patrz testy izolujące każdą awarię w małym modelu GPT w PyTorch.

Dlaczego tokeny znakowe przestają wystarczać

Tokeryzator znaków przypisuje każdemu znakowi, włączając spacje, własny token. Weźmy tę krótką frazę:

artificial intelligence

Przekształca się to w jeden token na każdy znak:

['a', 'r', 't', 'i', 'f', 'i', 'c', 'i', 'a', 'l',
 ' ', 'i', 'n', 't', 'e', 'l', 'l', 'i', 'g', 'e',
 'n', 'c', 'e']

To 23 tokeny dla dwóch słów. Tokenizer podsłówkowy mógłby przedstawić tę samą frazę przy znacznie mniejszej liczbie elementów, na przykład:

['art', 'ificial', ' intelligence']

Trzy tokeny zamiast 23. Krótsze sekwencje oznaczają więcej tekstu w oknie kontekstowym, mniej pozycji do trenowania oraz mniej kroków dekodowania podczas generowania. Model otrzymuje również znaczące fragmenty zamiast izolowanych liter, częste fragmenty stają się jednostkami wielokrotnie używalnymi, a rzadkie słowa nadal można zapisać z mniejszych części. Trudnym zadaniem jest automatyczne znajdowanie dobrych jednostek podsłówkowych, co właśnie realizuje BPE.

Porównanie znaków, słów i podsłówków

Istnieją trzy główne sposoby dzielenia tekstu, a każdy z nich wiąże się z innym kompromisem.

Tokowanie znaków

Każdy znak jest tokenem:

"learning"
→ ["l", "e", "a", "r", "n", "i", "n", "g"]

Oferuje małą liczbę słownictwa, brak nieznanych tokenów oraz prostą implementację. W zamian otrzymujesz długie sekwencje, wolne generowanie oraz model, który musi tworzyć słowa z pojedynczych liter.

Tokowizacja słów

Każde słowo jest tokenem:

"machine learning works"
→ ["machine", "learning", "works"]

Sekwencje są krótkie i łatwe do odczytania, ale słownictwo staje się ogromne – nieznane słowa wymagają specjalnych tokenów, a formy pokrewne, takie jak "learn", "learning" i "learned", mogą stać się trzema niepowiązanymi identyfikatorami bez żadnych wspólnych cech. Nazwy, błędy ortograficzne oraz rzadkie słowa są źle obsługiwane.

Tokenizacja podsłów

Słowa są dzielone na często występujące fragmenty:

"unbelievable"
→ ["un", "believ", "able"]

To daje krótsze sekwencje niż litery oraz mniejszą liczbę słów w słowniku niż w przypadku zwykłych słów. Często występujące słowa mogą nadal stanowić pojedyncze tokeny, rzadkie słowa rozkładają się na znane elementy, a fragmenty takie jak prefiksy i sufiksy są ponownie wykorzystywane w wielu słowach. Jedynym rzeczywistym kosztem jest to, że szkolenie i wdrożenie tokenera jest bardziej skomplikowane.

Można postrzegać te trzy podejścia jako punkty na spektrum:

Characters
← flexibility

Subwords
← balance

Words
← compactness

Jak BPE uczy się swoich łączeń

BPE zaczyna od najmniejszych jednostek, czyli pojedynczych znaków, i wielokrotnie łączy najczęściej występujące pary sąsiadujących znaków w nowy token. Rozważmy mały zbiór trzech słów:

low
lower
lowest

Początkowo każde słowo to po prostu jego znaki:

l o w
l o w e r
l o w e s t

Następnie wymień pary sąsiadujących symboli, które występują:

(l, o)
(o, w)
(w, e)
(e, r)
(e, s)
(s, t)

Jedna para wyróżnia się:

(l, o)

Występuje we wszystkich trzech słowach, dlatego łączy się w jeden symbol:

(l, o) → lo

Po zastąpieniu go we wszystkich miejscach korpus wygląda następująco:

lo w
lo w e r
lo w e s t

Po ponownym liczeniu najczęstszym parametrem jest teraz:

(lo, w)

Pojawia się również trzy razy, więc on też zostaje połączony:

(lo, w) → low

A korpus staje się:

low
low e r
low e s t

Po wystarczającej liczbie rund najczęstsze sekwencje stają się samodzielnymi tokenami.

Co daje pętla treningowa

Procedura, którą zamierzasz zaimplementować, obejmuje następujące kroki:

Training text
→ initialize character tokens
→ count adjacent token pairs
→ select most frequent pair
→ merge the selected pair
→ add merged token to vocabulary
→ repeat until target vocabulary size

Tworzy dwa pliki artefaktów, a oba są potrzebne do późniejszego użycia tokeryzatora.

Słownictwo

Słownictwo mapuje każdą ścieżkę tokena na całkowitą wartość identyfikacyjną, na przykład:

"a"       → 0
"b"       → 1
"th"      → 98
"ing"     → 127
" the"    → 204

Zasady łączenia

Zasady łączenia to uporządkowana lista par, które zostały połączone podczas treningu:

("t", "h") → "th"
("i", "n") → "in"
("in", "g") → "ing"
(" ", "the") → " the"

Porządek jest kluczowy. Późniejsza zasada, taka jak ("in", "g"), może zostać uruchomiona tylko wtedy, gdy wcześniejsza zasada, która stworzyła "in", została już zastosowana. Zastosowanie tych samych zasad w innej kolejności daje różne rozbicie tekstu.

Pierwsza implementacja na małym korpusie

Zanim przejdziesz do WikiText-2, przetestuj algorytm na ciągu znaków, który możesz obejrzeć gołym okiem:

text = "low lower lowest"

Rozbicie go na znaki daje listę początkowych tokenów:

tokens = list(text)
print(tokens)

Drukowanie pokazuje każdą literę, włącznie z przestrzeniami:

['l', 'o', 'w', ' ', 'l', 'o', 'w', 'e', 'r',
 ' ', 'l', 'o', 'w', 'e', 's', 't']

Aby znaleźć kandydatów do połączenia, policz każdą parę sąsiadów. Połączenie listy z samą sobą przesuniętą o jedną pozycję daje każdą parę sąsiadujących elementów, a Counter je liczy:

from collections import Counter


def count_pairs(tokens):
    return Counter(
        zip(tokens[:-1], tokens[1:])
    )

Zbadaj najczęstsze pary:

pair_counts = count_pairs(tokens)
print(pair_counts.most_common(10))

Wynik będzie wyglądał mniej więcej tak:

[(('l', 'o'), 3),
 (('o', 'w'), 3),
 (('w', 'e'), 2),
 ...]

Należy zauważyć, że (' ', 'l') również pojawia się w liczbach. Ponieważ spacery są zwykłymi znakami w tym schemacie, BPE może nauczyć się tokenów zawierających spaczek na początku, co jest podstawą powstawania później tokenów takich jak " the".

Łączenie pojedynczej pary

Główna operacja przegląda listę tokenów od lewej do prawej. Gdy bieżący token i następny odpowiadają wybranej parze, wydziela się połączony token i przeskakuje o dwa pozycje; w przeciwnym razie kopiuje się bieżący token i przesuwa się o jedną pozycję. Przecięcia są rozwiązywane od lewej do prawej: w a a a z parą (a, a) łączą się tylko pierwsze dwa.

def merge_pair(
    tokens,
    pair,
    merged_token,
):
    output = []
    index = 0

    while index < len(tokens):
        if (
            index < len(tokens) - 1
            and tokens[index] == pair[0]
            and tokens[index + 1] == pair[1]
        ):
            output.append(merged_token)
            index += 2
        else:
            output.append(tokens[index])
            index += 1

    return output

Zastosuj to, aby połączyć l i o:

tokens = merge_pair(
    tokens=tokens,
    pair=("l", "o"),
    merged_token="lo",
)

print(tokens)

Wynik jest krótszy od tekstu wejściowego:

['lo', 'w', ' ', 'lo', 'w', 'e', 'r',
 ' ', 'lo', 'w', 'e', 's', 't']

Każde połączenie usuwa jeden token za każdą wystąpienie pary, i to właśnie stąd pochodzi kompresja.

Powtarzanie aż do wypełnienia słownika

Minimalny trener po prostu iteruje: liczy pary, wybiera najczęściej występującą, łączy ją we wszystkich miejscach, zapisuje nowy token oraz regułę, a następnie przestaje, gdy słownik osiągnie wymaganą wielkość. Przestaje również wcześniej, jeśli nie pozostaną żadne pary lub jeśli najlepsza para występuje mniej niż dwa razy, ponieważ w takim przypadku łączenie nie tworzy już niczego, co można by ponownie wykorzystać.

def train_bpe_on_tokens(
    tokens,
    target_vocab_size,
):
    vocabulary = set(tokens)
    merges = []

    while len(vocabulary) < target_vocab_size:
        pair_counts = count_pairs(tokens)

        if not pair_counts:
            break

        best_pair, frequency = (
            pair_counts.most_common(1)[0]
        )

        if frequency < 2:
            break

        merged_token = "".join(best_pair)

        tokens = merge_pair(
            tokens=tokens,
            pair=best_pair,
            merged_token=merged_token,
        )

        vocabulary.add(merged_token)
        merges.append(best_pair)

        print(
            f"Merged {best_pair} "
            f"into {merged_token!r}, "
            f"frequency={frequency}"
        )

    return tokens, vocabulary, merges

Uruchomienie tego na prostym łańcuchu z małym celem wystarcza, aby obserwować, jak łączenia zachodzą po jednym:

final_tokens, vocabulary, merges = (
    train_bpe_on_tokens(
        tokens=list(text),
        target_vocab_size=20,
    )
)

Ta wersja jest przydatna do rozwijania intuicji, ale przy każdej iteracji ponownie skanuje całą listę w Pythonie. W przypadku pełnego korpusu WikiText staje się to bardzo wolne, dlatego praktyczna wersja poniżej trenuje się na konfigurowalnym fragmencie WikiText-2.

Projektowanie tokenizera wielokrotnego użytku

Klasa tokenizera powinna trenować się na surowym tekście, zaczynając od poszczególnych znaków, kodować i dekodować dane, odrzucać znaki spoza swojego słownika, zapisywać i ładować informacje w formacie JSON oraz obliczać „odpis” sekwencji. Wokół niej skrypt ponownie koduje każdy fragment z WikiText-2 i pokazuje, o ile krótsze są sekwencje BPE.

Jedna decyzja projektowa wymaga jasnego sformułowania. Ten tokenizer pracuje z znakami Unicode, a nie z surowymi bajtami. Tokenizery używane w dużych modelach są zazwyczaj na poziomie bajtów, co oznacza, że ich podstawowym słownikiem są 256 możliwych wartości bajtowych, więc żaden wprowadzony danych nie może być nieprzedstawiony. Wersja oparta na znakach stosuje ten sam algorytm i jest łatwiejsza do odczytania, ale nie jest pełnym odpowiednikiem tych tokenizerów.

Skelet klasy

Konstruktor przyjmuje podstawowe znaki oraz uporządkowaną listę łączeń. Tworzy pełną listę tokenów, zaczynając od podstawowych znaków i dodając wynik każdego połączenia, pomijając duplikaty. Na podstawie tej listy tworzy funkcje stoi (string na ID) oraz itos (ID na string) i zapisuje rozmiar słownika. Ponieważ ID pochodzą z pozycji w liście, te same podstawowe tokeny i łączenia zawsze dają te same ID.

class BPETokenizer:
    def __init__(
        self,
        base_tokens,
        merges,
    ):
        self.base_tokens = list(
            base_tokens
        )

        self.merges = [
            tuple(pair)
            for pair in merges
        ]

        self.tokens = list(
            self.base_tokens
        )

        for left, right in self.merges:
            merged = left + right

            if merged not in self.tokens:
                self.tokens.append(merged)

        self.stoi = {
            token: index
            for index, token in enumerate(
                self.tokens
            )
        }

        self.itos = {
            index: token
            for token, index
            in self.stoi.items()
        }

        self.vocab_size = len(
            self.tokens
        )

Wynikowy słownictwo składa się zatem z dwóch części:

Base character tokens
+
learned merged tokens

Szkolenie na rzeczywistym tekście

Szkolenie staje się metodą klasową, która zwraca gotowy do użycia tokenizer. Sortuje unikalne znaki tekstu w celu utworzenia podstawowego słownika, odrzuca rozmiar docelowy mniejszy od tej podstawowej bazy, a następnie uruchamia tę samą pętlę liczenia, wyboru i łączenia co wcześniej. Logowanie jest ograniczane: wyświetla każde z pierwszych 20 połączeń, a potem co setne, wraz z częstotliwością pary i obecną długością sekwencji, dzięki czemu można obserwować postęp kompresji bez przytłoczenia ilością wyjść.

@classmethod
def train(
    cls,
    text,
    target_vocab_size,
    minimum_pair_frequency=2,
    verbose=True,
):
    base_tokens = sorted(
        set(text)
    )

    if target_vocab_size < len(base_tokens):
        raise ValueError(
            "target_vocab_size cannot be "
            "smaller than the number of "
            "unique characters"
        )

    working_tokens = list(text)
    vocabulary = set(base_tokens)
    merges = []

    while len(vocabulary) < target_vocab_size:
        pair_counts = Counter(
            zip(
                working_tokens[:-1],
                working_tokens[1:],
            )
        )

        if not pair_counts:
            break

        best_pair, frequency = (
            pair_counts.most_common(1)[0]
        )

        if frequency < minimum_pair_frequency:
            break

        merged_token = "".join(
            best_pair
        )

        working_tokens = merge_pair(
            tokens=working_tokens,
            pair=best_pair,
            merged_token=merged_token,
        )

        merges.append(best_pair)
        vocabulary.add(merged_token)

        if (
            verbose
            and (
                len(merges) <= 20
                or len(merges) % 100 == 0
            )
        ):
            print(
                f"Merge {len(merges):4d}: "
                f"{best_pair!r} "
                f"→ {merged_token!r}, "
                f"frequency={frequency:,}, "
                f"tokens={len(working_tokens):,}"
            )

    return cls(
        base_tokens=base_tokens,
        merges=merges,
    )

Dlaczego istnieje minimalna częstotliwość pary

Jeśli najczęściej występująca pozostała para pojawia się tylko raz, jej połączenie tworzy token, który nigdy nie zostanie ponownie wykorzystany, co jedynie marnuje miejsce w słowniku. Dlatego pętla zatrzymuje się w takiej sytuacji:

frequency < minimum_pair_frequency

Dla takich eksperymentów wystarcza próg wynoszący dwa:

minimum_pair_frequency = 2

W większym korpusie tekstowym warto ustawić wyższy próg, aby słownictwo było wykorzystywane tylko do wzorców występujących wystarczająco często, aby model mógł nauczyć się dla nich dobrych reprezentacji.

Kodowanie i dekodowanie za pomocą tokenizera

Kodowanie składa się z trzech kroków:

  1. Rozbicie tekstu na podstawowe znaki.
  2. Wykonanie każdej reguły łączenia w kolejności, w której została nauczona.
  3. Znalezienie ID każdego powstałego tokena.

Pierwsza połowa tych działań znajduje się w funkcji encode_tokens. Zaczyna się od zebrania wszystkich znaków brakujących w słowniku i wywołuje wyraźny błąd, jeśli takie istnieją, co jest o wiele łatwiejsze do naprawienia niż błąd KeyError w trakcie przetwarzania zbioru danych. Następnie wykonywane są operacje łączenia.

def encode_tokens(self, text):
    unknown_characters = sorted({
        char
        for char in text
        if char not in self.stoi
    })

    if unknown_characters:
        raise ValueError(
            "Text contains characters missing "
            "from the base vocabulary: "
            f"{unknown_characters}"
        )

    tokens = list(text)

    for pair in self.merges:
        merged_token = (
            pair[0] + pair[1]
        )

        tokens = merge_pair(
            tokens=tokens,
            pair=pair,
            merged_token=merged_token,
        )

    return tokens

Przekształcenie uzyskanych ciągów tokenów w ID to prosta operacja wyszukiwania:

def encode(self, text):
    tokens = self.encode_tokens(text)

    return [
        self.stoi[token]
        for token in tokens
    ]

Dekodowanie jest jeszcze prostsze. Każdy token, niezależnie od tego, czy został połączony, czy nie, przechowuje dokładny tekst, który obejmuje, więc dekodowanie polega po prostu na łączeniu:

def decode(self, token_ids):
    return "".join(
        self.itos[int(token_id)]
        for token_id in token_ids
    )

To nadaje BPE właściwość, którą warto wyraźnie przetestować – bezstratną transmisję w obie strony:

decode(encode(text)) = text

Szybka weryfikacja przy użyciu zdania przykładowego:

sample = "The history of science."
encoded = tokenizer.encode(sample)
decoded = tokenizer.decode(encoded)
assert decoded == sample

Kontynuowanie pracy i ponowne ładowanie tokeryzatora

Nie musisz przechowywać samego słownika, ponieważ można go odbudować w sposób deterministyczny na podstawie podstawowych tokenów oraz listy łączenia. Metoda save zapisuje te dwa elementy do pliku JSON wraz z etykietą typu i rozmiarem słownika dla wygody. Ustawienie ensure_ascii=False zapewnia czytelność znaków nie-ASCII w pliku, a katalog nadrzędny zostanie utworzony, jeśli jeszcze nie istnieje.

def save(self, path):
    data = {
        "type": "character_bpe",
        "base_tokens": self.base_tokens,
        "merges": [
            [left, right]
            for left, right in self.merges
        ],
        "vocab_size": self.vocab_size,
    }

    path = Path(path)

    path.parent.mkdir(
        parents=True,
        exist_ok=True,
    )

    with open(
        path,
        "w",
        encoding="utf-8",
    ) as file:
        json.dump(
            data,
            file,
            ensure_ascii=False,
            indent=2,
        )

Wynikiem jest jeden plik:

tokenizer/
└── bpe_tokenizer.json

Ładowanie odwraca ten proces, czytając JSON i przekazując obie listy do konstruktora:

@classmethod
def from_file(cls, path):
    with open(
        path,
        "r",
        encoding="utf-8",
    ) as file:
        data = json.load(file)

    return cls(
        base_tokens=data[
            "base_tokens"
        ],
        merges=data["merges"],
    )

Następnie sprawdź, czy ponownie załadowany tokenizer nadal przetwarza tekst przykładowy w obie strony:

loaded_tokenizer = (
    BPETokenizer.from_file(
        "tokenizer/bpe_tokenizer.json"
    )
)

assert (
    loaded_tokenizer.decode(
        loaded_tokenizer.encode(sample)
    )
    == sample
)

Szkolenie na fragmentie WikiText-2

Ponieważ prosty trener ponownie skanuje całą sekwencję pracy przy każdym połączeniu, szkolenie całego WikiText-2 z dużym słownictwem może zająć dużo czasu. Zacznij od konfigurowalnego prefiksu tekstu treningowego:

training_characters = 500_000

training_text = train_text[
    :training_characters
]

Rozsądna początkowa wielkość słownictwa to:

target_vocab_size = 500

Innymi słowy:

Initial character vocabulary
+
learned merged tokens
=
approximately 500 tokens

Gdy wszystko działa poprawnie, spróbuj większych celów:

target_vocab_size = 1_000
target_vocab_size = 2_000

Oczekuj, że czas szkolenia będzie rosł wraz z rozmiarem próbki oraz liczbą łączeń.

Bieranie znaków podstawowych z każdego podziału

Jeśli słownictwo podstawowe jest tworzone wyłącznie na podstawie próbki szkoleniowej, tekst walidacyjny lub testowy może zawierać znaki, które w niej nigdy się nie pojawiły, co spowoduje niepowodzenie kodowania tych podziałów. Rozwiązaniem jest zebranie zestawu znaków podstawowych ze wszystkich trzech podziałów, jednocześnie nadal ucząc statystyk łączeń wyłącznie na podstawie danych szkoleniowych:

all_text = (
    train_text
    + val_text
    + test_text
)

base_tokens = sorted(
    set(all_text)
)

To nie wprowadza żadnych wzorców języka walidacji do połączeń. Gwarantuje jedynie, że każdy znak w zestawie danych ma identyfikator. Aby to umożliwić, trener ma do dyspozycji opcjonalny argument base_tokens, który jest używany zamiast znaków z tekstu treningowego, jeśli został podany:

@classmethod
def train(
    cls,
    text,
    target_vocab_size,
    minimum_pair_frequency=2,
    base_tokens=None,
    verbose=True,
):
    if base_tokens is None:
        base_tokens = sorted(
            set(text)
        )
    else:
        base_tokens = sorted(
            set(base_tokens)
        )

Pomiar uzyskanej kompresji

Aby zobaczyć, co daje BPE, porównaj liczbę tokenów w tym samym tekście. Weź przykład:

sample_text = train_text[:10_000]

Liczba tokenów znakowych to po prostu jego długość:

character_count = len(sample_text)

Liczba tokenów BPE odpowiada długości zapisanej listy identyfikatorów:

bpe_ids = tokenizer.encode(
    sample_text
)

bpe_count = len(bpe_ids)

Stosunek między nimi to współczynnik kompresji:

compression_factor = (
    character_count / bpe_count
)

Wynik może wyglądać w ten sposób:

Character tokens: 10,000
BPE tokens: 4,250
Compression: 2.35×

Z współczynnikiem kompresji 2,35 blok 128 tokenów BPE obejmuje mniej więcej tyle tekstu, co 301 tokenów składających się z liter. Kompresja nie jest jednak jednolita: proza pełna powszechnie używanych słów kompresuje się dobrze, natomiast liczby, rzadkie nazwy i nietypowa interpunkcja kompresują się słabo.

Przyjrzenie się temu, czego nauczył się tokenizer

Nauczone tokeny to wszystko w słowniku po podstawowych znakach. Ułożenie ich według długości i wyświetlenie najdłuższych wraz z ich identyfikatorami daje szybkie pojęcie o tym, co tokenizer uważa za częste:

learned_tokens = (
    tokenizer.tokens[
        len(tokenizer.base_tokens):
    ]
)

longest_tokens = sorted(
    learned_tokens,
    key=len,
    reverse=True,
)

for token in longest_tokens[:50]:
    print(
        repr(token),
        tokenizer.stoi[token],
    )

Zazwyczaj zobaczysz tokeny w następującym formacie:

" the"
"ing"
"tion"
" of"
"ed"
" and"

Właściwy wykaz zależy od przykładu treningowego, przetwarzania wstępnego, wielkości słownika docelowego, minimalnej częstotliwości występowania oraz zasad rozstrzygania w przypadku remisów. Jeśli chodzi o rozstrzyganie remisów: Counter.most_common zwraca pary o jednakowej częstotliwości w kolejności, w której zostały po raz pierwszy policzone, więc wyniki są deterministyczne dla danego wejścia, ale zmiana przykładu może zmienić kolejność, w której wygrają pary remisujące.

Ponowne kodowanie zestawu danych

Używając wyszkolonego tokenizatora, należy zakodować wszystkie trzy podzbiory:

train_bpe_ids = tokenizer.encode(
    train_text
)

val_bpe_ids = tokenizer.encode(
    val_text
)

test_bpe_ids = tokenizer.encode(
    test_text
)

Przekształć listy ID na tensorzy typu long, który jest typem oczekiwanym przez warstwy embedding do przechowywania indeksów:

train_tensor = torch.tensor(
    train_bpe_ids,
    dtype=torch.long,
)

val_tensor = torch.tensor(
    val_bpe_ids,
    dtype=torch.long,
)

test_tensor = torch.tensor(
    test_bpe_ids,
    dtype=torch.long,
)

Zapisz je do ich własnego katalogu:

data/
└── encoded_bpe/
    ├── train_ids.pt
    ├── val_ids.pt
    └── test_ids.pt

Zostaw pliki z kodowaniem znaków nietknięte; przechowywanie obu ułatwia późniejsze porównanie i zapobiega podawaniu jednemu modelowi identyfikatorów drugiego.

Walidacja zakodowanych podziałów

Najpierw każdy identyfikator musi znajdować się w zakresie od zera do wielkości słownika, ale bez uwzględnienia tej wartości:

for split_name, token_ids in [
    ("train", train_tensor),
    ("validation", val_tensor),
    ("test", test_tensor),
]:
    assert token_ids.min().item() >= 0

    assert (
        token_ids.max().item()
        < tokenizer.vocab_size
    )

    print(
        split_name,
        token_ids.shape,
        token_ids.min().item(),
        token_ids.max().item(),
    )

Po drugie, przykład z każdego podzielenia powinien wrócić w obie strony do oryginalnego tekstu:

for text in [
    train_text[:1_000],
    val_text[:1_000],
    test_text[:1_000],
]:
    encoded = tokenizer.encode(text)
    decoded = tokenizer.decode(encoded)

    assert decoded == text

Włączanie BPE do modelu

Architektura modelu nie wymaga żadnych zmian strukturalnych. Zmienia się jedynie rozmiar słownika w konfiguracji:

config = MiniGPTConfig(
    vocab_size=bpe_tokenizer.vocab_size,
    block_size=128,
    embedding_dim=128,
    num_heads=4,
    num_layers=4,
    expansion_factor=4,
    dropout=0.1,
)

Skieruj ładowanie danych na nowe pliki:

train_ids = torch.load(
    "data/encoded_bpe/train_ids.pt"
)

Od teraz model przewiduje tokeny BPE zamiast znaków.

Stare punkty kontrolne nie zostaną załadowane

Model oparty na znakach zazwyczaj ma słownictwo w tym zakresie:

100 to 300 character tokens

Model BPE zazwyczaj będzie wyglądał mniej więcej tak:

500 to 2,000 subword tokens

Zarówno embedding tokenów, jak i głowa wyjściowa zależą od wielkości słownika, dlatego ich struktura ulega zmianie. Punkt kontrolny znaku zawiera:

token embedding: [character_vocab_size, C]

Chociaż model BPE oczekuje:

token embedding: [bpe_vocab_size, C]

Tutaj C to wymiar embeddingu. Kształty nie są zgodne, więc stare wagi nie mogą zostać bezpośrednio załadowane. A nawet gdyby to było możliwe, ID oznaczają różne rzeczy w zależności od tokenizera. Musisz trenować nowy model, a pomocne jest przechowywanie oddzielnych punktów kontrolnych:

checkpoints_char/
checkpoints_bpe/

Lub, równoważnie:

checkpoints/char/
checkpoints/bpe/

Identyfikacja tokenera

Niezgodność kształtu jest wyraźnie widoczna; łączenie punktu kontrolnego z innym tokenerem BPE o tej samej wielkości pozostaje niewidoczne, ponieważ każdy identyfikator odpowiada po prostu niewłaściwemu tekstu. Fingerprint generuje hasz w postaci kompaktowego formatu JSON bazowych tokenów i łączy go z SHA-256, dzięki czemu każda zmiana powoduje powstanie nowej wartości.

import hashlib


def tokenizer_fingerprint(tokenizer):
    payload = json.dumps(
        {
            "base_tokens": (
                tokenizer.base_tokens
            ),
            "merges": [
                list(pair)
                for pair in tokenizer.merges
            ],
        },
        ensure_ascii=False,
        separators=(",", ":"),
    ).encode("utf-8")

    return hashlib.sha256(
        payload
    ).hexdigest()

Zachowaj odcisk palca wewnątrz każdego punktu kontrolnego:

checkpoint[
    "tokenizer_fingerprint"
] = tokenizer_fingerprint(
    tokenizer
)

Podczas ładowania oblicz odcisk palca aktywnego tokenizera i odmów dalszej pracy, jeśli nie pokrywa się on z tym zapisanym w punkcie kontrolnym.

Ograniczenie wydajności, o którym powinieneś wiedzieć

Kodownik ponownie przetwarza każdą zasadę łączenia w całej sekwencji tokenów:

for pair in self.merges:
    tokens = merge_pair(...)

To oznacza jedno pełne przetworzenie tekstu przy każdym połączeniu, więc duże teksty z tysiącami połączeń są kodowane powoli. Implementacja celowo faworyzuje klarowność, poprawność i możliwość inspekcji ponad szybkość. Tokenizery używane w produkcji dzielą tekst z góry na słowa, przechowują w pamięci cache powtarzające się segmentacje i stosują połączenia według rankingu; w celach edukacyjnych lepsza okazuje się wersja przejrzysta.

Pełny program

Umieść wszystko razem w jednym skrypcie:

build_bpe_tokenizer.py

Pełny program poniżej łączy narzędzia pomocnicze do par, klasę tokenizatora, funkcje walidacji oraz punkt wejścia w linii poleceń. Dwie kwestie różnią się od przedstawionych fragmentów: funkcja zapisu teraz przechowuje odcisk palca i zapisuje dane przez plik tymczasowy, który jest później przemieniony w plik docelowy, dzięki czemu przerwany proces nigdy nie pozostawia niekompletnego pliku, a proces szkolenia sprawdza, czy podstawowy słowniczek obejmuje tekst treningowy. Główna funkcja szkoli model na skonfigurowanym prefiksie, zapisuje i ponownie ładuje tokenizator, sprawdza odciski palców, wykonuje pętle testowe i zapisuje każdy podzielony fragment, weryfikuje zakresy ID oraz wyświetla statystyki.

Jedna linia w pętli „najdłuższych pozyskanych tokenów” wydaje się być uszkodzona: f"{loaded_tokenizer.stoi5d} " powoduje błąd AttributeError, ponieważ funkcja stoi5d nie istnieje. Intencją było prawdopodobnie użycie f"{loaded_tokenizer.stoi[token]:5d} ", które wyświetla ID każdego tokena wypełniony do pięciu znaków. Popraw tę linię przed uruchomieniem skryptu.

import argparse
import hashlib
import json
from collections import Counter
from pathlib import Path

import torch


# ==================================================
# Pair Operations
# ==================================================

def count_pairs(tokens):
    return Counter(
        zip(tokens[:-1], tokens[1:])
    )


def merge_pair(
    tokens,
    pair,
    merged_token,
):
    output = []
    index = 0

    while index < len(tokens):
        if (
            index < len(tokens) - 1
            and tokens[index] == pair[0]
            and tokens[index + 1] == pair[1]
        ):
            output.append(merged_token)
            index += 2
        else:
            output.append(tokens[index])
            index += 1

    return output


# ==================================================
# BPE Tokenizer
# ==================================================

class BPETokenizer:
    def __init__(
        self,
        base_tokens,
        merges,
    ):
        self.base_tokens = list(
            base_tokens
        )

        self.merges = [
            tuple(pair)
            for pair in merges
        ]

        self.tokens = list(
            self.base_tokens
        )

        for left, right in self.merges:
            merged_token = left + right

            if merged_token not in self.tokens:
                self.tokens.append(
                    merged_token
                )

        self.stoi = {
            token: index
            for index, token
            in enumerate(self.tokens)
        }

        self.itos = {
            index: token
            for token, index
            in self.stoi.items()
        }

        self.vocab_size = len(
            self.tokens
        )

    @classmethod
    def train(
        cls,
        text,
        target_vocab_size,
        minimum_pair_frequency=2,
        base_tokens=None,
        verbose=True,
    ):
        if not text:
            raise ValueError(
                "Training text cannot be empty"
            )

        if base_tokens is None:
            base_tokens = sorted(
                set(text)
            )
        else:
            base_tokens = sorted(
                set(base_tokens)
            )

        missing_characters = (
            set(text) - set(base_tokens)
        )

        if missing_characters:
            raise ValueError(
                "Base vocabulary is missing "
                "training characters"
            )

        if (
            target_vocab_size
            < len(base_tokens)
        ):
            raise ValueError(
                "target_vocab_size cannot be "
                "smaller than the number of "
                "base tokens"
            )

        working_tokens = list(text)
        vocabulary = set(base_tokens)
        merges = []

        print(
            "Initial characters:",
            len(working_tokens),
        )

        print(
            "Base vocabulary size:",
            len(base_tokens),
        )

        while (
            len(vocabulary)
            < target_vocab_size
        ):
            pair_counts = count_pairs(
                working_tokens
            )

            if not pair_counts:
                print(
                    "No adjacent pairs remain."
                )
                break

            best_pair, frequency = (
                pair_counts.most_common(1)[0]
            )

            if (
                frequency
                < minimum_pair_frequency
            ):
                print(
                    "Stopping because the most "
                    "frequent pair occurs only "
                    f"{frequency} time(s)."
                )
                break

            merged_token = "".join(
                best_pair
            )

            working_tokens = merge_pair(
                tokens=working_tokens,
                pair=best_pair,
                merged_token=merged_token,
            )

            merges.append(best_pair)
            vocabulary.add(merged_token)

            merge_number = len(merges)

            if (
                verbose
                and (
                    merge_number <= 20
                    or merge_number % 100 == 0
                )
            ):
                print(
                    f"Merge {merge_number:4d}: "
                    f"{best_pair!r} "
                    f"→ {merged_token!r}, "
                    f"frequency={frequency:,}, "
                    f"sequence length="
                    f"{len(working_tokens):,}, "
                    f"vocabulary="
                    f"{len(vocabulary):,}"
                )

        tokenizer = cls(
            base_tokens=base_tokens,
            merges=merges,
        )

        print(
            "Final vocabulary size:",
            tokenizer.vocab_size,
        )

        print(
            "Final training sequence length:",
            len(working_tokens),
        )

        return tokenizer

    @classmethod
    def from_file(cls, path):
        with open(
            path,
            "r",
            encoding="utf-8",
        ) as file:
            data = json.load(file)

        return cls(
            base_tokens=data[
                "base_tokens"
            ],
            merges=data["merges"],
        )

    def encode_tokens(self, text):
        unknown_characters = sorted({
            char
            for char in text
            if char not in self.stoi
        })

        if unknown_characters:
            raise ValueError(
                "Text contains characters "
                "missing from the base vocabulary: "
                f"{unknown_characters}"
            )

        tokens = list(text)

        for left, right in self.merges:
            tokens = merge_pair(
                tokens=tokens,
                pair=(left, right),
                merged_token=left + right,
            )

        return tokens

    def encode(self, text):
        return [
            self.stoi[token]
            for token in self.encode_tokens(
                text
            )
        ]

    def decode(self, token_ids):
        return "".join(
            self.itos[int(token_id)]
            for token_id in token_ids
        )

    def save(self, path):
        path = Path(path)

        path.parent.mkdir(
            parents=True,
            exist_ok=True,
        )

        data = {
            "type": "character_bpe",
            "base_tokens": (
                self.base_tokens
            ),
            "merges": [
                [left, right]
                for left, right
                in self.merges
            ],
            "vocab_size": (
                self.vocab_size
            ),
            "fingerprint": (
                self.fingerprint()
            ),
        }

        temporary_path = path.with_suffix(
            ".tmp"
        )

        with open(
            temporary_path,
            "w",
            encoding="utf-8",
        ) as file:
            json.dump(
                data,
                file,
                ensure_ascii=False,
                indent=2,
            )

        temporary_path.replace(path)

    def fingerprint(self):
        payload = json.dumps(
            {
                "base_tokens": (
                    self.base_tokens
                ),
                "merges": [
                    list(pair)
                    for pair in self.merges
                ],
            },
            ensure_ascii=False,
            separators=(",", ":"),
        ).encode("utf-8")

        return hashlib.sha256(
            payload
        ).hexdigest()


# ==================================================
# Validation
# ==================================================

def validate_round_trip(
    tokenizer,
    text,
    split_name,
):
    encoded = tokenizer.encode(text)
    decoded = tokenizer.decode(encoded)

    if decoded != text:
        raise AssertionError(
            f"Round-trip failed for "
            f"{split_name}"
        )

    print(
        f"{split_name} round-trip passed: "
        f"{len(text):,} characters "
        f"→ {len(encoded):,} tokens"
    )

    return encoded


def save_token_tensor(
    token_ids,
    path,
):
    path = Path(path)

    path.parent.mkdir(
        parents=True,
        exist_ok=True,
    )

    tensor = torch.tensor(
        token_ids,
        dtype=torch.long,
    )

    temporary_path = path.with_suffix(
        ".tmp"
    )

    torch.save(
        tensor,
        temporary_path,
    )

    temporary_path.replace(path)

    return tensor


# ==================================================
# Arguments
# ==================================================

def parse_args():
    parser = argparse.ArgumentParser(
        description=(
            "Train a simple educational BPE "
            "tokenizer on WikiText-2"
        )
    )

    parser.add_argument(
        "--processed-dir",
        type=str,
        default="data/processed",
    )

    parser.add_argument(
        "--tokenizer-output",
        type=str,
        default=(
            "tokenizer/bpe_tokenizer.json"
        ),
    )

    parser.add_argument(
        "--encoded-output-dir",
        type=str,
        default="data/encoded_bpe",
    )

    parser.add_argument(
        "--target-vocab-size",
        type=int,
        default=500,
    )

    parser.add_argument(
        "--training-characters",
        type=int,
        default=500_000,
    )

    parser.add_argument(
        "--minimum-pair-frequency",
        type=int,
        default=2,
    )

    return parser.parse_args()


# ==================================================
# Main Program
# ==================================================

def main():
    args = parse_args()

    processed_dir = Path(
        args.processed_dir
    )

    train_path = (
        processed_dir
        / "wikitext2_train_clean.txt"
    )

    val_path = (
        processed_dir
        / "wikitext2_val_clean.txt"
    )

    test_path = (
        processed_dir
        / "wikitext2_test_clean.txt"
    )

    for path in [
        train_path,
        val_path,
        test_path,
    ]:
        if not path.exists():
            raise FileNotFoundError(
                f"Required file not found: "
                f"{path}"
            )

    train_text = train_path.read_text(
        encoding="utf-8"
    )

    val_text = val_path.read_text(
        encoding="utf-8"
    )

    test_text = test_path.read_text(
        encoding="utf-8"
    )

    print(
        "Training characters:",
        len(train_text),
    )

    print(
        "Validation characters:",
        len(val_text),
    )

    print(
        "Test characters:",
        len(test_text),
    )

    all_characters = sorted(
        set(
            train_text
            + val_text
            + test_text
        )
    )

    training_character_count = min(
        args.training_characters,
        len(train_text),
    )

    bpe_training_text = train_text[
        :training_character_count
    ]

    print(
        "\nBPE training characters:",
        len(bpe_training_text),
    )

    tokenizer = BPETokenizer.train(
        text=bpe_training_text,
        target_vocab_size=(
            args.target_vocab_size
        ),
        minimum_pair_frequency=(
            args.minimum_pair_frequency
        ),
        base_tokens=all_characters,
        verbose=True,
    )

    tokenizer.save(
        args.tokenizer_output
    )

    print(
        "\nTokenizer saved to:",
        args.tokenizer_output,
    )

    print(
        "Tokenizer fingerprint:",
        tokenizer.fingerprint(),
    )

    loaded_tokenizer = (
        BPETokenizer.from_file(
            args.tokenizer_output
        )
    )

    assert (
        loaded_tokenizer.fingerprint()
        == tokenizer.fingerprint()
    )

    print(
        "Tokenizer reload test passed."
    )

    print(
        "\nEncoding dataset splits..."
    )

    train_ids = validate_round_trip(
        tokenizer=loaded_tokenizer,
        text=train_text,
        split_name="Training",
    )

    val_ids = validate_round_trip(
        tokenizer=loaded_tokenizer,
        text=val_text,
        split_name="Validation",
    )

    test_ids = validate_round_trip(
        tokenizer=loaded_tokenizer,
        text=test_text,
        split_name="Test",
    )

    output_dir = Path(
        args.encoded_output_dir
    )

    train_tensor = save_token_tensor(
        train_ids,
        output_dir / "train_ids.pt",
    )

    val_tensor = save_token_tensor(
        val_ids,
        output_dir / "val_ids.pt",
    )

    test_tensor = save_token_tensor(
        test_ids,
        output_dir / "test_ids.pt",
    )

    for split_name, tensor in [
        ("Training", train_tensor),
        ("Validation", val_tensor),
        ("Test", test_tensor),
    ]:
        assert tensor.min().item() >= 0

        assert (
            tensor.max().item()
            < loaded_tokenizer.vocab_size
        )

        print(
            f"{split_name} tensor: "
            f"shape={tuple(tensor.shape)}, "
            f"min={tensor.min().item()}, "
            f"max={tensor.max().item()}"
        )

    character_tokens = len(
        train_text
    )

    bpe_tokens = len(
        train_ids
    )

    compression_factor = (
        character_tokens / bpe_tokens
    )

    reduction_percentage = (
        1.0
        - bpe_tokens / character_tokens
    ) * 100.0

    print(
        "\nCharacter token count:",
        f"{character_tokens:,}",
    )

    print(
        "BPE token count:",
        f"{bpe_tokens:,}",
    )

    print(
        "Compression factor:",
        f"{compression_factor:.2f}x",
    )

    print(
        "Token-count reduction:",
        f"{reduction_percentage:.2f}%",
    )

    learned_tokens = (
        loaded_tokenizer.tokens[
            len(
                loaded_tokenizer.base_tokens
            ):
        ]
    )

    longest_tokens = sorted(
        learned_tokens,
        key=len,
        reverse=True,
    )

    print("\nLongest learned tokens:")

    for token in longest_tokens[:30]:
        print(
            f"{loaded_tokenizer.stoi5d} "
            f"{token!r}"
        )

    sample = (
        "Artificial intelligence "
        "and machine learning"
    )

    unknown = [
        char
        for char in sample
        if char not in (
            loaded_tokenizer.stoi
        )
    ]

    if not unknown:
        sample_tokens = (
            loaded_tokenizer.encode_tokens(
                sample
            )
        )

        sample_ids = (
            loaded_tokenizer.encode(
                sample
            )
        )

        print("\nSample text:")
        print(sample)

        print("\nSample BPE tokens:")
        print(sample_tokens)

        print("\nSample token IDs:")
        print(sample_ids)

        print("\nDecoded sample:")
        print(
            loaded_tokenizer.decode(
                sample_ids
            )
        )

    print(
        "\nBPE tokenizer pipeline "
        "completed successfully."
    )


if __name__ == "__main__":
    main()

Uruchamianie

Domyślna konfiguracja to krótkie, edukacyjne uruchomienie:

python build_bpe_tokenizer.py

Aby poznać większy zasób słownictwa:

python build_bpe_tokenizer.py \
  --target-vocab-size 1000

Aby trenować na większej ilości tekstu:

python build_bpe_tokenizer.py \
  --training-characters 1000000 \
  --target-vocab-size 1000

Dla szybkiego testu dymnego, który jedynie potwierdza, że pipeline działa:

python build_bpe_tokenizer.py \
  --training-characters 100000 \
  --target-vocab-size 300

Jeśli twoja środowisko nie obsługuje kontynuacji wierszy za pomocą ukośnika odwrotnego, zapisz każdą komendę na osobnym wierszu.

Jak wygląda wynik

Wykonanie może wyświetlić coś w tym rodzaju:

Training characters: 10,000,000
Validation characters: 1,100,000
Test characters: 1,200,000

BPE training characters: 500,000
Initial characters: 500,000
Base vocabulary size: 200

Merge    1: (' ', 't') → ' t', frequency=19,842
Merge    2: ('h', 'e') → 'he', frequency=17,415
Merge    3: (' t', 'he') → ' the', frequency=14,108
...
Final vocabulary size: 500

Pierwsze łączenia są dokładnie takie, jak można by się spodziewać w języku angielskim: przestrzeń, a następnie t, potem he, a te dwa łączą się w " the". Po kodowaniu streszczenie wygląda mniej więcej tak:

Character token count: 10,000,000
BPE token count: 4,500,000
Compression factor: 2.22x
Token-count reduction: 55.00%

Twoje liczby będą się różnić. Należy pamiętać, że przetwarzanie całego zbioru danych treningowych za pomocą tego kodera może znacząco wydłużyć czas działania przy większych słownictwach.

Co zmienia się w modelu i jego oknie kontekstowym

Liczba tokenów w rzeczywistym zdaniu

Weźmy to zdanie:

The history of artificial intelligence

Tokenizer znaków wytwarza mniej więcej tyle tokenów:

38 tokens

Tokenizer BPE może tworzyć tokeny całych słów w takim wydaniu:

["The", " history", " of", " artificial", " intelligence"]

Albo może tworzyć mniejsze fragmenty, w zależności od fuzji, których się nauczyło. W obu przypadkach liczba mieści się w przedziale takim jak:

5 to 15 tokens

Segmentacja nie została stworzona ręcznie; wynika z statystyk korpusu treningowego.

Więcej tekstu w oknie kontekstowym

Załóżmy, że model używa takiej wielkości bloku:

block_size = 128

W przypadku znaków 128 tokenów obejmuje około 128 znaków. Z wykorzystaniem BPE te same 128 tokenów może obejmować kilkaset znaków. Model ma dostęp do większego kontekstu językowego bez konieczności zwiększania obliczeń związanych z uwagą. Jest to istotne, ponieważ koszt samouwagi rośnie mniej więcej wraz z:

T²

Tutaj T to długość sekwencji, więc podwojenie T w przybliżeniu czterokrotnie zwiększa koszt obliczeń związanych z uwagą, podczas gdy lepsza tokenizacja pozwala umieścić więcej tekstu w tym samym T.

Kompromis między rozmiarem słownika a efektywnością

Większy słownik BPE zazwyczaj oznacza krótsze sekwencje, ale jednocześnie powoduje większy rozmiar niektórych macierzy. Tabela embeddingów tokenów zawiera tyle właśnie parametrów:

vocab_size × embedding_dim

Przy wiązaniu wag głowa wyjściowa ponownie wykorzystuje tę samą macierz zamiast dodawać drugą. Porównaj małą leksykografię:

Vocabulary:          500
Embedding dimension: 128
Embedding parameters: 64,000

Z większym:

Vocabulary: 8,000
Embedding dimension: 128
Embedding parameters: 1,024,000

Zatem większy słowniczek skraca sekwencje, ale zwiększa parametry embeddingów, koszt logitów wyjściowych, zużycie pamięci oraz czas treningu tokenizatora. Każdy dodatkowy token wymaga również wystarczającej liczby wystąpień, aby móc nauczyć się dobrego embeddingu, dlatego w korpusie tak dużym jak WikiText-2 ogromny słowniczek skutkuje wieloma słabo przeszkolonymi tokenami. Odpowiednia wielkość zależy od zestawu danych i modelu.

Przeszkolenie modelu na tokenach BPE

Zaktualizuj skrypt treningowy, aby załadować plik tokenizatora:

tokenizer/bpe_tokenizer.json

A zakodowane podziały:

data/encoded_bpe/train_ids.pt
data/encoded_bpe/val_ids.pt
data/encoded_bpe/test_ids.pt

Następnie utwórz konfigurację na podstawie wielkości słownika tokeryzatora:

config = MiniGPTConfig(
    vocab_size=bpe_tokenizer.vocab_size,
    block_size=128,
    embedding_dim=128,
    num_heads=4,
    num_layers=4,
    expansion_factor=4,
    dropout=0.1,
)

I zapisz punkty kontrolne do swojego katalogu:

checkpoints/bpe/

Poza tymi zmianami pętla treningowa może pozostać w zasadzie taka sama.

Uczciwe porównanie dwóch modeli

Aby porównanie było sensowne, w miarę możliwości utrzymaj niezmienne architekturę (szerokość, głębokość, liczba głowic), dane, optymalizator, harmonogram, budżet treningowy oraz metodę oceny. Nawet wtedy porównywanie przy równym liczbie kroków treningowych jest mylące, ponieważ partia tokenów BPE zawiera znacznie więcej tekstu niż partia znaków. Lepszymi kryteriami do porównania są:

Total characters processed
Total bytes processed
Training time
Compute budget

Perplexity jest obliczana dla każdej jednostki przewidywania, dlatego perplexity znaków i BPE nie można porównywać bezpośrednio. Aby uzyskać porównywalną wartość, należy przekształcić obie wartości na bity na znak; w przeciwnym razie należy oceniać każdy model pod kątem jakości generowania, obliczać koszt oraz metryki odpowiednie dla tokenera.

Częste błędy

Tworzenie oddzielnego tokenera dla każdego podziału

To jest błędne:

Train tokenizer from training text
Validation tokenizer from validation text
Test tokenizer from test text

Dane treningowe, walidacyjne i testowe muszą być wszystkie zakodowane za pomocą tego samego tokenizera; w przeciwnym razie ten sam identyfikator oznacza różne rzeczy w różnych podzbiórach danych.

Stosowanie łączeń w niewłaściwej kolejności

Łączenia budują się jedno na drugim, dlatego muszą być wykonywane dokładnie w tej kolejności, w której zostały nauczone:

for pair in self.merges:
    apply(pair)

Sortowanie zasad, na przykład alfabetycznie dla lepszej czytelności, zmienia sposób segmentacji tekstu.

Zapisywanie plików z kodowaniem znaków

Zachowaj ID BPE w ich własnym katalogu:

data/encoded_bpe/

I zostaw ID postaci tam, gdzie były:

data/encoded/

Uczenie się na podstawie tekstu walidacyjnego

Statystyki fuzji powinny pochodzić wyłącznie z tekstu treningowego. Wykorzystywanie podstawowego zestawu znaków z każdego podziału jest w porządku pod kątem kompletności, ale częstotliwości fuzji muszą odzwierciedlać wyłącznie korpus treningowy, w przeciwnym razie liczby z testu walidacyjnego będą zbyt optymistyczne.

Oczekiwanie, że utworzone tokeny będą pełnymi słowami

BPE z przyjemnością uczy się fragmentów takich jak:

"ing"
"tion"
" th"
" arti"

Tokeny podsłówkowe kierują się częstotliwością, a nie ludzkimi granicami słów, i to jest oczekiwane zachowanie.

Przypuszczenie, że większy słowniczek zawsze jest lepszy

Większe słowniczki powodują wzrost kosztów parametrów oraz tworzą rzadkie, słabo wyszkolone tokeny. Spróbuj kilku rozmiarów i sprawdź efekty. Podobnie, nigdy nie porównuj surowej wartości perplexity pomiędzy różnymi tokenizatorami.

Ćwiczenia

  1. Porównaj trzy rozmiary słowniczka. Wytrenuj model przy użyciu podanych rozmiarów i porównaj czas treningu, długość sekwencji, stopień kompresji, najdłuższe tokeny oraz rozmiar wektorów embeddingów.
300 tokens
500 tokens
1,000 tokens
  1. Przestudiuj wczesne fuzje. Wydrukuj pierwsze 50 reguł fuzji. Czy pojawiają się wzory odstępów? Częste sufiksy? Kombinacje znaków interpunkcyjnych? Które fuzje cię zaskoczyły?
  2. Ztokalizuj jedno zdanie na dwa sposoby. Zapisz następujące zdanie za pomocą zarówno tokulatora znaków, jak i tokulatora BPE, a następnie porównaj tokeny, ID, liczbę tokenów oraz wynik dekodowania.
Artificial intelligence is transforming technology.
  1. Próbuj rzadkich słów. Zapisz kodem następujące słowa i obserwuj, w jaki sposób BPE składa nieznane słowa z znanych elementów.
electromagnetism
neurocomputational
uncharacteristically
  1. Mierz zakres pokrycia kontekstu. Dekoduj pierwsze 128 tokenów w ramach każdego tokenizera i sprawdź, jaki obszar tekstu obejmuje każde okno.
  2. Wytrenuj model BPE. Wytrenuj go na poniższej katalogu i porównaj jego przykłady z modelem opartym na znakach przy równych zasobach obliczeniowych.
data/encoded_bpe/
  1. Wdrożenie identyfikatorów palców. Przechowuj identyfikator palca tokenera w każdym punkcie kontrolnym i odmawiaj wykonywania inferencji, gdy punkt kontrolny i aktywny tokener się różnią.
  2. Zwiększenie szybkości kodowania. Zastąp kodownik działający w jednej rundzie przy łączeniu czymś szybszym, wykorzystującym rankingi łączeń, stosy, łączone listy tokenów, pamięci cache na poziomie słów lub mechanizmy typu tries.

Zasady tokenizacji, które warto zachować

  • Podsłowa znajdują się pomiędzy elastycznymi, lecz długimi znakami a zwartymi, lecz kruchymi słowami.
  • Tokener BPE jest w pełni określony przez swoje podstawowe tokeny oraz ich uporządkowane łączenia; zapisuj i identyfikuj dokładnie to.
  • Kodowanie odtwarza łączenia w kolejności, dekodowanie łączy je ze sobą, a procesy w obie strony odbywają się bez strat.
  • Jeden tokener służy do każdego podziału, a łączenia są uczone wyłącznie na podstawie danych treningowych.
  • Nowy tokenizer oznacza nowe pliki tokenów, większą wielkość słownika oraz świeżo wytrenowany model.
  • Wielkość słownika pozwala na krótsze sekwencje w zamian za większe warstwy embeddingu i wyjściowe, a wartość perplexity nie przenosi się pomiędzy tokenizatorami.
  • Pełny proces, od surowego tekstu do ponownie wytrenowanego modelu, wygląda następująco:

    WikiText training text
    → character vocabulary
    → pair frequencies
    → repeated merge operations
    → BPE vocabulary and merge rules
    → encode all splits
    → save token tensors
    → train a new Mini-GPT
    

    Chodzi tu o to, że tokenizacja nie jest tylko szczegółem przetwarzania wstępnego. Określa ona, jakie jednostki uczy się model, ile tekstu mieści się w jego oknie kontekstowym oraz jak duży jest problem predykcji wyników. Gdy mały model GPT pracuje z tokenami BPE, naturalnym następnym krokiem jest zobaczenie, jak ta sama mechanika generowania kolejnych tokenów rozwija się w nowoczesne systemy sztucznej inteligencji generatywnej:

    Next-token predictor
    → pretrained language model
    → instruction-following model
    → retrieval-augmented application
    → tool-using GenAI system
    

    Podobne artykuły

  • Od GPT-1 do modeli rozumowania: Co każda generacja zmieniła dla rozwojowców: Prześledź rodzinę GPT od wstępnego szkolenia z 2018 roku aż po modele rozumowania, zobacz, jakie pomysły dodała każda generacja, oraz poznanie funkcji GPT-4o Vision i Structured Outputs w Pythonie.
  • Szkolenie małego LLM od zera na laptopie z 8 GB RAM: Jak skonfigurować czysty pipeline danych do szkolenia modelu typu transformer na skromnym sprzęcie: tokenizer BPE, TinyStories, tokeny zapisywane na dysku oraz grupowanie kolejnych tokenów.