Токенізатор BPE з нуля: коротші послідовності для малого вікна контексту GPT
Напишіть токенайзер для кодування пар байтів у Python, навчіть його на WikiText-2, збережіть результати об’єднання у форматі JSON та подивіться, як субсловесна токенізація розширює вікно контексту GPT.
Джерела: Tutorial 16: Beyond Character Tokens, Building a BPE Tokenizer
Токенізатор на рівні символів — це найпростіший спосіб запустити невеликий GPT: словниковий запас дуже малий, нічого не є невідомим, і кожен крок є прозорим. Ціною є довжина послідовності, адже кожна літера коштує позиції в вікні контексту. Цей посібник замінює його на кодування пар байтів (BPE), реалізоване з нуля на звичайному Python. Ви навчите його на WikiText-2, збережете навчений словниковий запас та злитки у форматі JSON, створите їх відбиток, перекодуєте набір даних та побачите, що саме змінюється під час повторного навчання моделі на субсловесних токенах.
Код передбачає наявність вже створеного невеликого GPT на PyTorch та очищених файлів WikiText-2. Якщо навчання все одно не працює належним чином, спочатку стабілізуйте його; дивіться тести, які ізолюють кожну проблему в невеликому GPT на PyTorch.
Чому символьні токени більше недостатні
Токенізатор символів надає кожному символу, включаючи пробіли, власний токен. Візьмемо цю коротку фразу:
artificial intelligence
Це перетворюється на один токен на кожен символ:
['a', 'r', 't', 'i', 'f', 'i', 'c', 'i', 'a', 'l',
' ', 'i', 'n', 't', 'e', 'l', 'l', 'i', 'g', 'e',
'n', 'c', 'e']
Це 23 токени для двох слів. Токенізатор підслів може представити ту саму фразу значно меншою кількістю елементів, наприклад:
['art', 'ificial', ' intelligence']
Три токени замість 23. Коротші послідовності означають більше тексту в окні контексту, менше позицій для навчання та менше кроків декодування під час генерації. Модель також отримує змістовні фрагменти замість ізольованих літер, часті уривки стають повторно використовуваними одиницями, а рідкісні слова все ще можна скласти з менших частин. Складною є задача автоматичного виявлення хороших підсловесних одиниць, саме це й робить BPE.
Порівняння символів, слів та підслів
Існує три основні способи розділення тексту, кожен з яких передбачає певний компроміс.
Токенізація символів
Кожен символ є токеном:
"learning"
→ ["l", "e", "a", "r", "n", "i", "n", "g"]
Він пропонує невеликий словниковий запас, відсутність невідомих токенів та просту реалізацію. У відповідь ви отримуєте довгі послідовності, повільну генерацію та модель, яка змушена складати слова з окремих літер.
Токенізація слів
Кожне слово є токеном:
"machine learning works"
→ ["machine", "learning", "works"]
Послідовності є короткими та зрозумілими, але словниковий запас стає величезним; невідомі слова потребують спеціальних токенів, а схожі форми, такі як "learn", "learning" та "learned", можуть перетворитися на три незалежні ідентифікатори без жодних спільних рис. Імена, помилки у написанні та рідкісні слова обробляються погано.
Субсловесна токенізація
Слова розділяються на часто зустрічаючіся фрагменти:
"unbelievable"
→ ["un", "believ", "able"]
Це дає коротші послідовності, ніж літери, та менший словниковий запас, ніж слова. Часто вживані слова все одно можуть стати окремими токенами, рідкісні слова розкладаються на відомі частини, а фрагменти, такі як префікси та суфікси, використовуються знову та знову у багатьох словах. Єдиним справжнім недоліком є те, що навчання та реалізація токенізатора є більш складними.
Ви можете розглядати ці три підходи як точки на спектрі:
Characters
← flexibility
Subwords
← balance
Words
← compactness
Як BPE вивчає процес злиття
BPE починає з найменших одиниць — тут окремих символів — і постійно зливає найпоширенішу пару сусідніх символів у новий токен. Розгляньмо невеликий корпус з трьох слів:
low
lower
lowest
Спочатку кожне слово — це просто його символи:
l o w
l o w e r
l o w e s t
Далі перелічте пари сусідніх символів, які зустрічаються:
(l, o)
(o, w)
(w, e)
(e, r)
(e, s)
(s, t)
Одна пара вирізняється:
(l, o)
Він зустрічається у всіх трьох словах, тому його об’єднують у один символ:
(l, o) → lo
Після заміни його всюди корпус виглядає так:
lo w
lo w e r
lo w e s t
Якщо підрахувати знову, найчастішою парою зараз є:
(lo, w)
Він також з’являється тричі, тому його також об’єднують:
(lo, w) → low
І корпус стає таким:
low
low e r
low e s t
Після достатньої кількості раундів найчастіші послідовності стають окремими токенами.
Що дає цикл навчання
Процедура, яку ви збираєтесь реалізувати, включає наступні кроки:
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
Він створює два артефакти, і вам знадобляться обидва, щоб пізніше використати токенайзер.
Словник
Словник відповідає кожній рядку токенів інтегральному ідентифікатору, наприклад:
"a" → 0
"b" → 1
"th" → 98
"ing" → 127
" the" → 204
Правила злиття
Правила злиття — це впорядкований список пар, які були об’єднані під час навчання:
("t", "h") → "th"
("i", "n") → "in"
("in", "g") → "ing"
(" ", "the") → " the"
Порядок є критично важливим. Пізніше правило, таке як ("in", "g"), може бути застосоване лише тоді, коли попереднє правило, яке створило "in", вже було застосоване. Застосування тих самих правил у іншому порядку призводить до іншої сегментації.
Перша реалізація на навчальному корпусі
Перш ніж працювати з WikiText-2, опрацюйте алгоритм на рядку, який можна побачити візуально:
text = "low lower lowest"
Розділення на символи дає список початкових токенів:
tokens = list(text)
print(tokens)
Його друк показує кожен символ, включаючи пробіли:
['l', 'o', 'w', ' ', 'l', 'o', 'w', 'e', 'r',
' ', 'l', 'o', 'w', 'e', 's', 't']
Щоб знайти кандидатів для об’єднання, підрахуйте кожну пару сусідів. З’єднання списку з самим собою, зсунутого на одну позицію, дає кожну сусідню пару, а Counter підраховує їх:
from collections import Counter
def count_pairs(tokens):
return Counter(
zip(tokens[:-1], tokens[1:])
)
Перевірте найпоширеніші пари:
pair_counts = count_pairs(tokens)
print(pair_counts.most_common(10))
Результат буде виглядати приблизно так:
[(('l', 'o'), 3),
(('o', 'w'), 3),
(('w', 'e'), 2),
...]
Зауважте, що (' ', 'l') також з’являється у підрахунках. Оскільки пробіли є звичайними символами в цій схемі, BPE може навчитися розпізнавати токени, які містять пробіл на початку, і саме так пізніше виникають такі токени, як " the".
Об’єднання однієї пари
Основна операція просувається по списку токенів зліва направо. Кожного разу, коли поточний токен та наступний відповідають обраній парі, вона генерує об’єднаний токен та пропускає два елементи вперед; інакше вона копіює поточний токен та просувається на один елемент. Накладання вирішується зліва направо: у a a a з парою (a, a) об’єднуються лише перші два.
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
Використайте його для об’єднання l та o:
tokens = merge_pair(
tokens=tokens,
pair=("l", "o"),
merged_token="lo",
)
print(tokens)
Результат є коротшим за вхідний текст:
['lo', 'w', ' ', 'lo', 'w', 'e', 'r',
' ', 'lo', 'w', 'e', 's', 't']
Кожне об’єднання видаляє один токен за кожне зустрічання пари, і саме з цього походить стиснення.
Повторення до повного заповнення словника
Мінімальний тренер просто циклізує: підраховує пари, обирає найчастішу, об’єднує її всюди, записує новий токен та правило, і зупиняється, коли розмір словника досягає бажаного значення. Він також зупиняється раніше, якщо пар не залишилося або якщо найкраща пара зустрічається менше ніж двічі, адже в такому випадку об’єднання більше не створює нічого, що можна було б використати знову.
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
Якщо запустити його на простій мішені з короткою мотузкою, достатньо буде спостерігати, як злиття відбуваються по одному:
final_tokens, vocabulary, merges = (
train_bpe_on_tokens(
tokens=list(text),
target_vocab_size=20,
)
)
Ця версія підходить для формування інтуїції, але кожна ітерація знову сканує весь список Python. На повному корпусі WikiText це робить процес дуже повільним, тому практична версія, наведена нижче, навчається на налаштовуваній частині WikiText-2.
Проектування повторно використовуваного токенізатора
Клас токенізатора має навчатися на сирому тексті, починаючи з окремих символів, кодувати та декодувати їх, відкидати символи, які не належать до його словника, зберігати та завантажувати дані у форматі JSON, а також обчислювати «відбиток» тексту. Навколо нього скрипт знову кодує кожну частину WikiText-2 та повідомляє, наскільки коротшими стають послідовності BPE.
Одне з рішень щодо дизайну варто чітко сформулювати. Цей токенайзер працює з символами Unicode, а не з сирими байтами. Токенайзери, які використовуються у великих моделях, зазвичай працюють на рівні байтів, що означає, що їхня базова лексика складається з 256 можливих значень байтів, і жоден вхідний даний не може залишитися нерепрезентованим. Версія, заснована на символах, дотримується того ж алгоритму та є простішою для читання, але вона не є прямим еквівалентом цих токенайзерів.
Скелет класу
Конструктор приймає базові символи та впорядкований список для об’єднання. Він створює повний список токенів, починаючи з базових символів та додаючи результат кожного об’єднання, пропускаючи дублікати. З цього списку він формує функції пошуку stoi (строка → ID) та itos (ID → строка) та фіксує розмір лексики. Оскільки ID формуються за позиціями в списку, ті самі базові токени та операції об’єднання завжди дають однакові 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
)
Отже, отриманий словник складається з двох частин:
Base character tokens
+
learned merged tokens
Навчання на реальному тексті
Навчання перетворюється на метод класу, який повертає готовий до використання токенізатор. Він сортує унікальні символи тексту для створення базового словника, відхиляє значення мінімального обсягу, менше за цю базову сукупність, а потім виконує той самий цикл підрахунку, вибору та об’єднання, що й раніше. Логування обмежується: він виводить кожне з перших 20 об’єднань, а потім кожне стохвильове, разом із частотою пар та поточною довжиною послідовності, щоб ви могли стежити за прогресом стиснення, не потопаючи у великій кількості даних.
@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,
)
Чому існує мінімальна частота пари
Якщо найчастіша залишена пара зустрічається лише один раз, її об’єднання створює токен, який ніколи не буде використаний знову, що є марною втратою місця у словнику. Тому цикл зупиняється за таких умов:
frequency < minimum_pair_frequency
Для таких експериментів достатньо порогового значення два:
minimum_pair_frequency = 2
У більшому корпусі даних може знадобитися вищий поріг, щоб словниковий запас використовувався лише для тих шаблонів, які зустрічаються достатньо часто, щоб модель могла навчитися створювати якісні ембеддинги для них.
Кодування та декодування за допомогою токенайзера
Кодування відбувається у трьох кроках:
- Розділити текст на базові символи.
- Виконати кожне правило об’єднання у тому порядку, в якому воно було навчене.
- Знайти ID кожного отриманого токена.
Перша половина цієї роботи реалізована у функції encode_tokens. Спочатку вона збирає всі символи, яких бракує у словниковому запасі, та створює чітку помилку, якщо такі є, що значно простіше для дебагування, ніж KeyError посеред набору даних. Потім вона виконує операції об’єднання.
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
Перетворення отриманих рядків токенів на ID — це просте пошукове запитування:
def encode(self, text):
tokens = self.encode_tokens(text)
return [
self.stoi[token]
for token in tokens
]
Декодування ще простіше. Кожен токен, незалежно від того, об’єднаний він чи ні, зберігає саме той текст, який він охоплює, тож декодування — це просто об’єднання:
def decode(self, token_ids):
return "".join(
self.itos[int(token_id)]
for token_id in token_ids
)
Це надає BPE властивості, яку варто явно перевірити — безвтратну обробку даних у обох напрямках:
decode(encode(text)) = text
Швидка перевірка за допомогою прикладного речення:
sample = "The history of science."
encoded = tokenizer.encode(sample)
decoded = tokenizer.decode(encoded)
assert decoded == sample
Зберігання та перезавантаження токенайзера
Вам не потрібно зберігати сам словник, оскільки його можна детерміновано відновити з базових токенів та списку злиття. Метод save записує ці два елементи у форматі JSON разом із позначкою типу та розміром словника для зручності. Встановлення параметра ensure_ascii=False забезпечує читабельність некодуваних символів у файлі, а якщо батьківський каталог ще не існує, він створюється.
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,
)
Результатом є один файл:
tokenizer/
└── bpe_tokenizer.json
Завантаження зворотно перетворює цей процес, читаючи JSON та передаючи обидві списки конструктору:
@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"],
)
Потім переконайтеся, що після перезавантаження токенізатора він все ще обробляє зразок тексту:
loaded_tokenizer = (
BPETokenizer.from_file(
"tokenizer/bpe_tokenizer.json"
)
)
assert (
loaded_tokenizer.decode(
loaded_tokenizer.encode(sample)
)
== sample
)
Навчання на фрагменті WikiText-2
Оскільки простий тренер пересканує всю послідовність даних під час кожного об’єднання, навчання всього WikiText-2 із великим словниковим запасом може зайняти багато часу. Почніть із налаштовуваного префіксу тексту для навчання:
training_characters = 500_000
training_text = train_text[
:training_characters
]
Розумний розмір початкового словникового запасу становить:
target_vocab_size = 500
Іншими словами:
Initial character vocabulary
+
learned merged tokens
=
approximately 500 tokens
Коли все працює, спробуйте більші цілі:
target_vocab_size = 1_000
target_vocab_size = 2_000
Очікуйте, що час навчання зростатиме як зі збільшенням розміру зразка, так і з кількістю злиттів.
Використання базових символів з кожного розділення
Якщо базовий словник складається лише з даних навчання, текст для перевірки чи тестування може містити символи, яких у ньому ніколи не було, і кодування цих розділень зазнає невдачі. Рішенням є збір базового набору символів з усіх трьох розділень, при цьому статистику злиттів продовжуючи обчислювати лише на основі даних навчання:
all_text = (
train_text
+ val_text
+ test_text
)
base_tokens = sorted(
set(all_text)
)
Це не дозволяє проникати жодних шаблонів мови валідації під час злиття даних. Воно лише гарантує, що кожен символ у наборі даних має ідентифікатор. Для підтримки цього функціоналу тренер отримує необов’язковий аргумент base_tokens, який використовується замість символів навчального тексту, якщо він наданий:
@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)
)
Вимірювання ступеня стиснення
Щоб побачити, яку користь принесе BPE, порівняйте кількість токенів у одному й тому ж уривку. Візьміть зразок:
sample_text = train_text[:10_000]
Кількість токенів його символів дорівнює просто його довжині:
character_count = len(sample_text)
Кількість токенів BPE дорівнює довжині списку кодованих ID:
bpe_ids = tokenizer.encode(
sample_text
)
bpe_count = len(bpe_ids)
Співвідношення між ними є коефіцієнтом стиснення:
compression_factor = (
character_count / bpe_count
)
Результат може виглядати ось так:
Character tokens: 10,000
BPE tokens: 4,250
Compression: 2.35×
З коефіцієнтом стиснення 2,35 блок з 128 токенів BPE охоплює приблизно стільки ж тексту, скільки 301 токен з символів. Однак стиснення не є однорідним: проза, насичена поширеними словами, стискається добре, тоді як числа, рідкісні імена та незвичайна пунктуація — погано.
Що навчився токенізатор
Навчені токени — це все у словнику після базових символів. Відсортувавши їх за довжиною та вивівши найдовші з їхніми ідентифікаторами, можна швидко зрозуміти, що токенізатор вважає частими:
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],
)
Зазвичай ви побачите токени приблизно у такому вигляді:
" the"
"ing"
"tion"
" of"
"ed"
" and"
Точний список залежить від зразка навчання, попередньої обробки, розміру цільового словника, мінімальної частоти та правил розв’язання ситуацій з рівною частотою. Щодо правил розв’язання ситуацій з рівною частотою: Counter.most_common повертає пари з однаковою частотою у тому порядку, в якому вони були вперше підраховані, тому результати є детермінованими для даного вхідного даних, але зміна зразка може змінити те, яка з кількох пар з рівною частотою стане переможницею.
Перекодування набору даних
За допомогою навченого токенайзера необхідно закодувати всі три частини даних:
train_bpe_ids = tokenizer.encode(
train_text
)
val_bpe_ids = tokenizer.encode(
val_text
)
test_bpe_ids = tokenizer.encode(
test_text
)
Перетворіть списки ID на тензори типу long — це тип даних, який очікують шари вбудовування для індексів:
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,
)
Збережіть їх у власному каталозі:
data/
└── encoded_bpe/
├── train_ids.pt
├── val_ids.pt
└── test_ids.pt
Не торкайтеся файлів у форматі кодування символів; зберігання обох варіантів полегшує подальше порівняння та уникає подачі ID однієї моделі іншій.
Перевірка закодованих розділів
По-перше, кожен ID має знаходитися в діапазоні від нуля до розміру словника, не включаючи останній:
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(),
)
По-друге, зразок з кожної частини має повернутися назад до початкового тексту:
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
Інтеграція BPE у модель
Архітектура моделі не потребує структурних змін. Змінюється лише розмір словника в налаштуваннях:
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,
)
Направте завантаження даних на нові файли:
train_ids = torch.load(
"data/encoded_bpe/train_ids.pt"
)
Відтепер модель прогнозує токени BPE замість символів.
Старі чекпоїнти не завантажуватимуться
У моделі символів словник зазвичай має такі розміри:
100 to 300 character tokens
Модель BPE зазвичай має щось на кшталт:
500 to 2,000 subword tokens
Як ембеддинг токенів, так і вихідний блок залежать від розміру словника, тому їхня структура змінюється. Пункт перевірки символа має:
token embedding: [character_vocab_size, C]
Хоча модель BPE очікує:
token embedding: [bpe_vocab_size, C]
Тут C — це розмір вбудовування. Форми не збігаються, тому старі ваги не можна завантажити безпосередньо. І навіть якби це було можливо, ID означають різні речі у кожному токенайзері. Вам потрібно навчити нову модель, і корисно тримати чекпоїнти окремо:
checkpoints_char/
checkpoints_bpe/
Або, еквівалентно:
checkpoints/char/
checkpoints/bpe/
Фінгерпринтинг токенайзера
Невідповідність форми є помітною; поєднання чекпоїнта з іншим токенайзером BPE такого ж розміру залишається непомітним, оскільки кожен ідентифікатор просто відповідає неправильному тексту. Фінгерпринт генерується шляхом хешування компактної форми JSON базових токенів та поєднується з SHA-256, тож будь-яка зміна призводить до нового значення.
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()
Зберігайте відбиток пальця всередині кожної точки перевірки:
checkpoint[
"tokenizer_fingerprint"
] = tokenizer_fingerprint(
tokenizer
)
Під час завантаження обчислюйте відбиток пальця активного токенайзера та відмовляйтеся від подальшої роботи, якщо він не збігається з тим, що зберігся разом із чекпоїнтом.
Обмеження продуктивності, про яке варто знати
Енкодер переграє кожне правило об’єднання для всієї послідовності токенів:
for pair in self.merges:
tokens = merge_pair(...)
Це означає один повний проходження тексту під час кожного об’єднання, тому великі тексти з тисячами об’єднань кодуються повільно. Реалізація свідомо віддає перевагу чіткості, правильності та можливості перевірки перед швидкістю. У продакшн-застосунках токенізатори заздалегідь ділять текст на слова, зберігають у кеші повторювані розбіжності та застосовують об’єднання за пріоритетом; для навчання кращою є прозора версія.
Повна програма
Об’єднайте все в одному скрипті:
build_bpe_tokenizer.py
Повна програма нижче поєднує в собі функції допомоги для пар, клас токенізатора, засоби перевірки та точку входу в командному рядку. Два аспекти відрізняються від фрагментів коду: тепер під час збереження зберігається відбиток, а дані записуються через тимчасовий файл, який потім перейменовується на файл-ціль, тож перерваний запуск ніколи не залишає напівзаписаного файлу; крім того, під час навчання перевіряється, чи базовий словник охоплює текст для навчання. Основна функція навчається на налаштованому префіксі, зберігає та завантажує знову токенізатор, перевіряє відбитки, виконує подвійні операції з даними та зберігає кожну частину, перевіряє діапазони ID та виводить статистику.
У циклі „найдовших навчених токенів“ здається, що один рядок було пошкоджено: f"{loaded_tokenizer.stoi5d} " спричинить помилку AttributeError, оскільки stoi5d не існує. Ймовірно, потрібен вираз f"{loaded_tokenizer.stoi[token]:5d} ", який виводить ID кожного токена, доповнений до п’яти символів. Виправте цей рядок перед запуском скрипту.
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()
Запуск
Стандартна конфігурація передбачає невеликий, навчальний запуск:
python build_bpe_tokenizer.py
Щоб опанувати більший словниковий запас:
python build_bpe_tokenizer.py \
--target-vocab-size 1000
Щоб тренуватися з більшим обсягом тексту також:
python build_bpe_tokenizer.py \
--training-characters 1000000 \
--target-vocab-size 1000
Для швидкого тесту на дим, який лише підтверджує, що конвеєр працює:
python build_bpe_tokenizer.py \
--training-characters 100000 \
--target-vocab-size 300
Якщо ваша оболонка не підтримує продовження рядків за допомогою зворотного слеша, вводьте кожну команду в окремий рядок.
Як виглядає результат
Під час виконання може бути виведено щось на кшталт цього:
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
Перші об’єднання саме такі, як ви можете очікувати для англійської мови: пробіл за яким йде t, потім he, а потім ці два символи об’єднуються у " the". Після кодування резюме виглядає приблизно так:
Character token count: 10,000,000
BPE token count: 4,500,000
Compression factor: 2.22x
Token-count reduction: 55.00%
Ваші показники будуть відрізнятися. Зверніть увагу, що обробка всієї частини даних для навчання за допомогою цього кодувача може суттєво збільшити час виконання при великих словниках.
Що змінюється у моделі та її вікні контексту
Кількість токенів у реальному реченні
Візьмемо це речення:
The history of artificial intelligence
Токенізатор символів створює приблизно стільки токенів:
38 tokens
Токенізатор BPE може створювати токени цілих слів на кшталт цих:
["The", " history", " of", " artificial", " intelligence"]
Або воно може створювати менші фрагменти, залежно від того, які злиття воно опанувало. У будь-якому разі кількість буде знаходитися в такому діапазоні:
5 to 15 tokens
Сегментація не створюється вручну; вона випливає зі статистики навчального корпусу.
Більше тексту у вікні контексту
Припустимо, що модель використовує такий розмір блоку:
block_size = 128
Зі знаками 128 токенів охоплюють приблизно 128 знаків. За допомогою BPE ці ж 128 токенів можуть охоплювати кілька сотень знаків. Модель бачить більше мовного контексту, не збільшуючи при цьому обчислення уваги. Це важливо, оскільки витрати на самоувагу зростають приблизно пропорційно:
T²
Тут T — це довжина послідовності, тож подвоєння T приблизно збільшує витрати на обробку уваги в чотири рази, тоді як краща токенізація дозволяє помістити більше тексту в ту саму довжину T.
Компроміс між розміром словника та ефективністю
Більший словник BPE зазвичай означає коротші послідовності, але водночас робить певні матриці більшими. Таблиця ембеддингів токенів містить стільки параметрів:
vocab_size × embedding_dim
З використанням зв’язування ваг, вихідний головний модуль використовує ту саму матрицю замість того, щоб додавати другу. Порівняйте невеликий словниковий запас:
Vocabulary: 500
Embedding dimension: 128
Embedding parameters: 64,000
З більшим:
Vocabulary: 8,000
Embedding dimension: 128
Embedding parameters: 1,024,000
Отже, більший словниковий запас скорочує довжину послідовностей, але підвищує параметри ембеддингу, вартість логітів вихідних даних, використання пам’яті та час навчання токенізатора. Кожен додатковий токен також потребує достатньої кількості зустрічей, щоб навчитися ефективному ембеддингу, тому у корпусі розміром з WikiText-2 великий словниковий запас призводить до появи багатьох майже ненавчених токенів. Оптимальний розмір залежить від набору даних та моделі.
Поновне навчання моделі на токенах BPE
Оновіть скрипт навчання, щоб він завантажував файл токенізатора:
tokenizer/bpe_tokenizer.json
І кодовані розділення:
data/encoded_bpe/train_ids.pt
data/encoded_bpe/val_ids.pt
data/encoded_bpe/test_ids.pt
Потім створіть конфігурацію на основі розміру словника токенізатора:
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,
)
І записуйте точки перевірки у власний каталог:
checkpoints/bpe/
Окрім цих змін, цикл навчання може залишатися практично таким самим, як раніше.
Справедливе порівняння двох моделей
Для змістовного порівняння, наскільки це можливо, залишайте незмінною архітектуру (ширину, глибину, кількість блоків обробки), дані, оптимізатор, графік навчання, обсяг ресурсів та метод оцінки. Навіть тоді порівняння при однаковій кількості кроків навчання є оманливим, оскільки пакет токенів BPE містить значно більше тексту, ніж пакет символів. Кращими критеріями для порівняння є:
Total characters processed
Total bytes processed
Training time
Compute budget
Показник перплекситету вимірюється за одиницею прогнозування, тому показники перплекситету для символів та BPE не можна порівнювати безпосередньо. Щоб отримати порівнювані значення, необхідно перетворити обидва показники на кількість бітів на символ; в іншому випадку оцінюйте кожну модель за якістю генерації, розраховуйте витрати та показники, придатні для токенайзера.
Поширені помилки
Створення окремого токенайзера для кожного розділення
Це неправильно:
Train tokenizer from training text
Validation tokenizer from validation text
Test tokenizer from test text
Дані навчання, підтвердження та тестування повинні бути закодовані за допомогою одного й того самого токенайзера; інакше один і той самий ID може означати різні речі у різних частинах даних.
Застосування об’єднань у неправильному порядку
Об’єднання ґрунтуються одне на одному, тому їх необхідно виконувати саме в тому порядку, в якому вони були навчені:
for pair in self.merges:
apply(pair)
Сортування правил, наприклад, алфавітно для кращої читабельності, змінює спосіб розділення тексту на частини.
Перезапис файлів у форматі кодування символів
Зберігайте ID BPE у власному каталозі:
data/encoded_bpe/
І залиште ідентифікатори символів там, де вони були:
data/encoded/
Навчання злиттів на основі тексту для перевірки
Статистика злиттів має походити лише з тексту навчання. Використання базового набору символів з кожної частини є прийнятним для охоплення, але частоти злиттів мають відображати лише текст навчання, інакше показники перевірки будуть заниженими.
Очікування того, що навчені токени будуть цілими словами
BPE з радістю вивчає такі фрагменти, як:
"ing"
"tion"
" th"
" arti"
Токени підслова ґрунтуються на частоті вживання, а не на людських межах слів, і це є очікуваною поведінкою.
Припущення про те, що чим більший словниковий запас, тим краще, завжди є помилковим
Більші словникові запаси збільшують кількість параметрів та створюють рідкісні, погано навчені токени. Спробуйте кілька розмірів та вимірюйте результати. Так само ніколи не порівнюйте первинну перплексність різних токенізаторів.
Вправи
- Порівняйте три розміри словникового запасу. Навчіть модель з використанням наведених нижче розмірів та порівняйте час навчання, довжину послідовностей, рівень стиснення, довжину найдовших токенів та розмір ембеддингів.
300 tokens
500 tokens
1,000 tokens
- Вивчіть ранні операції об’єднання. Роздрукуйте перші 50 правил об’єднання. Чи зустрічаються шаблони пробілів? Поширені суфікси? Комбінації розділових знаків? Які операції об’єднання вас здивували?
- Токенізуйте одне речення обома способами. Закодуйте наступне речення як за допомогою токенізатора символів, так і токенізатора BPE, та порівняйте токени, ідентифікатори, кількість токенів та результат декодування.
Artificial intelligence is transforming technology.
- Перевірте рідкісні слова. Закодуйте наступні слова та спостерігайте, як BPE складає незнайомі слова з відомих частин.
electromagnetism
neurocomputational
uncharacteristically
- Вимірюйте охоплення контексту. Розшифруйте перші 128 токенів під кожним токенайзером та подивіться, яку частину тексту охоплює кожне вікно.
- Навчіть модель BPE. Навчіть її за допомогою каталогу нижче та порівняйте її зразки з моделлю символів при однакових ресурсах обчислень.
data/encoded_bpe/
- Застосовувати фінгерпринти. Зберігати фінгерпринт токенайзера у кожному чекпоїнті та відмовлятися від виконання обробки, коли чекпоїнт та активний токенайзер не збігаються.
- Прискорити кодування. Замінити кодер, який виконує один прохід під час об’єднання, на щось швидше, використовуючи механізми рангів об’єднань, купи, посилані списки токенів, кеші за словом чи інші аналогічні методи.
Правила токенізації, які варто зберігати
- Підслова розташовуються між гнучкими, але довгими символами та компактними, але крихкими словами.
- Токенайзер BPE повністю визначається його базовими токенами та їх послідовними об’єднаннями; потрібно зберігати саме це та створювати для нього фінгерпринт.
- Під час кодування об’єднання відтворюються у порядку, під час декодування — об’єднуються, і процеси кодування/декодування відбуваються без втрат.
- Один токенайзер використовується для кожного розбиття тексту, а об’єднання вивчаються лише на основі даних навчання.
Повний процес, від сирого тексту до перенавченої моделі, виглядає так:
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
Суть у тому, що токенізація — це не просто деталь попередньої обробки. Вона визначає, які одиниці вивчає модель, скільки тексту поміщається в її вікно контексту та наскільки великою є проблема прогнозування результату. Як тільки невелика модель GPT починає працювати з токенами BPE, наступним логічним кроком є дослідження того, як цей механізм формування наступних токенів розвивається у сучасні системи генеративного ШІ:
Next-token predictor
→ pretrained language model
→ instruction-following model
→ retrieval-augmented application
→ tool-using GenAI system
Пов’язана література
- Відлагодження невеликого GPT у PyTorch: тести, які ізолюють кожну помилку: Крок за кроком — процес відлагодження GPT на рівні символів у PyTorch, від ідентифікаторів токенів та зміни цілей до градієнтів, значень NaN та точок контролю.
- Створення AI-агента з нуля: шаблони, ReAct та LangGraph: Дізнайтеся про основні концепції AI-агентів, планування, використання інструментів, рефлексію та шаблон ReAct, а також про те, як LangChain та LangGraph допомагають створити такий агент вручну.