Zero to Hero, лекция 8: строим токенизатор GPT
Перевод текстовой версии лекции «Let's build the GPT Tokenizer», упражнений и README репозитория minbpe, а также пересказ остальной части видео: UTF-8, BPE своими руками, регулярные выражения GPT-2 и GPT-4, tiktoken, спецтокены, SentencePiece и странности LLM, которые растут из токенизации.
Восьмая лекция серии «Neural Networks: Zero to Hero» целиком посвящена токенизаторутокенизаторEN tokenizer: программе, которая превращает текст в последовательность чисел-токеновтокенEN tokenВ разговорной речи и статьях китайцы чаще всего пишут просто «token». и обратно. Карпатый пишет с нуля byte-level BPEBPE, кодирование пар байтовEN Byte Pair Encoding (BPE)Алгоритм токенизации — многократно сливает самую частую пару соседних токенов в новый токен. Изначально (Gage, 1994) — алгоритм сжатия данных. — тот же алгоритм, что у GPT-2 и GPT-4, — и показывает, сколько странностей языковых моделей на самом деле растёт отсюда.
Код лекции живёт в репозитории karpathy/minbpe под лицензией MIT. Там же лежит
текстовая версия лекции lecture.md, но Карпатый успел записать только её начало (примерно первые 15 минут видео) и
остановился на «продолжение следует». Поэтому страница устроена так:
- Часть 1 — полный перевод
lecture.md. - Часть 2 — пересказ остальной части видео своими словами, по главам из описания видео. Это не перевод.
- Часть 3 — полный перевод упражнений
exercise.md: как шаг за шагом написать свой токенизатор GPT-4. - Часть 4 — полный перевод README репозитория minbpe.
Как читать вместе с курсом
Заголовок раздела «Как читать вместе с курсом»Лекция опирается на предыдущую — «GPT с нуля»: там токенизатор был посимвольным, и словарь состоял из 65 символов. В курсе эта тема — модуль 4.2. Удобный порядок такой:
- сначала урок 4.2.1 «Зачем нужны токены» — интуиция и словарь;
- затем эта лекция целиком: часть 1 и главы части 2 до регулярных выражений включительно;
- затем урок 4.2.2 «Byte Pair Encoding с нуля» — тот же алгоритм на русском тексте, с заданием в браузере;
- остаток лекции (tiktoken, спецтокены, SentencePiece, странности) — вместе с уроком 4.2.3 «Токенизаторы современных моделей».
Где в модели живёт размер словаря (таблица эмбеддингов и lm_head), видно в уроке
4.6.2 «GPT на PyTorch». Новичку можно пропустить
разбор SentencePiece (1:28:42–1:43:27): он нужен, только если вы будете обучать токенизатор в стиле Llama.
Карта лекции
Заголовок раздела «Карта лекции»Таймкоды и названия глав — из описания видео.
| Время | Глава в оригинале | О чём |
|---|---|---|
| 0:00 | intro: Tokenization, GPT-2 paper, tokenization-related issues | зачем разбираться в токенизации |
| 5:50 | tokenization by example in a Web UI (tiktokenizer) | токены GPT-2 и GPT-4 наглядно |
| 14:56 | strings in Python, Unicode code points | что такое строка в Python |
| 18:15 | Unicode byte encodings, ASCII, UTF-8, UTF-16, UTF-32 | почему именно UTF-8 |
| 22:47 | daydreaming: deleting tokenization | мечта о модели без токенизатора |
| 23:50 | Byte Pair Encoding (BPE) algorithm walkthrough | BPE на игрушечном примере |
| 27:02 | starting the implementation | начинаем писать код |
| 28:35 | counting consecutive pairs, finding most common pair | get_stats |
| 30:36 | merging the most common pair | merge |
| 34:58 | training the tokenizer: adding the while loop, compression ratio | обучение и степень сжатия |
| 39:20 | tokenizer/LLM diagram: it is a completely separate stage | токенизатор — отдельный этап |
| 42:47 | decoding tokens to strings | decode |
| 48:21 | encoding strings to tokens | encode |
| 57:36 | regex patterns to force splits across categories | регулярное выражение GPT-2 |
| 1:11:38 | tiktoken library intro, differences between GPT-2/GPT-4 regex | tiktoken и шаблон GPT-4 |
| 1:14:59 | GPT-2 encoder.py released by OpenAI walkthrough | код токенизатора GPT-2 |
| 1:18:26 | special tokens, tiktoken handling of, GPT-2/GPT-4 differences | спецтокены |
| 1:25:28 | minbpe exercise time! write your own GPT-4 tokenizer | упражнения (часть 3) |
| 1:28:42 | sentencepiece library intro, used to train Llama 2 vocabulary | SentencePiece |
| 1:43:27 | how to set vocabulary set? revisiting gpt.py transformer | как выбрать размер словаря |
| 1:48:11 | training new tokens, example of prompt compression | новые токены, сжатие промпта |
| 1:49:58 | multimodal [image, video, audio] tokenization with vector quantization | токены для картинок и видео |
| 1:51:41 | revisiting and explaining the quirks of LLM tokenization | странности LLM и их причины |
| 2:10:20 | final recommendations | что использовать на практике |
Часть 1. Текст лекции (перевод lecture.md)
Заголовок раздела «Часть 1. Текст лекции (перевод lecture.md)»Оригинальный заголовок — «LLM Tokenization», «Токенизация в LLM».
Всем привет! Сегодня мы разберём токенизацию в больших языковых моделях (LLM). К сожалению, токенизация — довольно сложная и запутанная часть современных LLM. Но разобраться в ней в деталях необходимо: многие недостатки LLM, которые можно списать на нейросеть или которые выглядят загадочно, на самом деле растут из токенизации.
В прошлый раз: посимвольная токенизация
Заголовок раздела «В прошлый раз: посимвольная токенизация»Так что же такое токенизация? Оказывается, в прошлом видео, Let’s build GPT from scratch, мы уже ею занимались, но только очень простой, наивной, посимвольной версией. Если открыть Google Colab к тому видео, вы увидите, что мы начали с обучающих данных (Шекспир) — это просто одна большая строка в Python:
First Citizen: Before we proceed any further, hear me speak.
All: Speak, speak.
First Citizen: You are all resolved rather to die than to famish?
All: Resolved. resolved.
First Citizen: First, you know Caius Marcius is chief enemy to the people.
All: We know't, we know't.Но как подать строки в языковую модель? Мы видели, что для этого сначала строится словарь из всех символов, которые встречаются в обучающем наборе:
# все уникальные символы, которые встречаются в текстеchars = sorted(list(set(text)))vocab_size = len(chars)print(''.join(chars))print(vocab_size)
# !$&',-.3:;?ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz# 65Затем по этому словарю создаётся таблица соответствия между отдельными символами и целыми числами. Таблица — это просто словарь Python:
stoi = { ch:i for i,ch in enumerate(chars) }itos = { i:ch for i,ch in enumerate(chars) }# кодировщик: принимает строку, возвращает список целых чиселencode = lambda s: [stoi[c] for c in s]# декодировщик: принимает список целых чисел, возвращает строкуdecode = lambda l: ''.join([itos[i] for i in l])
print(encode("hii there"))print(decode(encode("hii there")))
# [46, 47, 47, 1, 58, 46, 43, 56, 43]# hii thereПревратив строку в последовательность целых чисел, мы использовали каждое число как индекс в двумерной таблице
эмбеддинговэмбеддинг, векторное представлениеEN embedding из обучаемых параметров. Размер словаря vocab_size=65, поэтому в этой
таблице тоже 65 строк:
class BigramLanguageModel(nn.Module):
def __init__(self, vocab_size): super().__init__() self.token_embedding_table = nn.Embedding(vocab_size, n_embd)
def forward(self, idx, targets=None): tok_emb = self.token_embedding_table(idx) # (B,T,C)Здесь целое число «выдёргивает» строку из таблицы эмбеддингов, и эта строка — вектор, который представляет токен. Дальше этот вектор подаётся в трансформер как вход на соответствующем шаге по времени.
«Кусочки символов»: токенизация алгоритмом BPE
Заголовок раздела ««Кусочки символов»: токенизация алгоритмом BPE»Для наивной посимвольной языковой модели всё это прекрасно работает. Но на практике в современных языковых моделях словари токенов строят гораздо более сложными способами. В частности, эти схемы работают не на уровне символов, а на уровне кусочков из нескольких символов. А сами словари кусочков строятся алгоритмами вроде Byte Pair Encoding (BPE, «кодирование пар байтов»), который мы подробно разберём ниже.
Немного истории. Использование byte-level BPE для токенизации в языковых моделях популяризировала статья о GPT-2 от OpenAI 2019 года — «Language Models are Unsupervised Multitask Learners» («Языковые модели — многозадачные ученики без учителя»). Прокрутите до раздела 2.2 «Input Representation» («Представление входа»): там этот алгоритм описан и обоснован. В конце раздела сказано:
Словарь расширен до 50 257. Мы также увеличиваем размер контекста с 512 до 1024 токенов и используем больший батч — 512.
Напомню: в слое внимания трансформера каждый токен смотрит на конечный список предыдущих токенов последовательности. Статья говорит, что у GPT-2 контекстконтекстное окноEN context window — 1024 токена, а у GPT-1 было 512. Иными словами, токены — это фундаментальные «атомы» на входе LLM. А токенизация — процесс, который превращает сырые строки Python в список токенов и обратно. Ещё один пример того, насколько эта абстракция вездесуща: если открыть статью о Llama 2 и поискать слово «token», найдётся 63 совпадения. Например, авторы пишут, что обучали модель на 2 триллионах токенов, и так далее.
Немного о том, почему токенизация сложна
Заголовок раздела «Немного о том, почему токенизация сложна»Прежде чем погрузиться в реализацию, коротко объясню, зачем вообще разбираться в токенизации в деталях. Токенизация стоит за множеством странностей LLM, и я советую не отмахиваться от неё. Многое из того, что выглядит как проблема архитектуры нейросети, на самом деле растёт из токенизации. Вот лишь несколько примеров:
- Почему LLM не умеет писать слова по буквам? Токенизация.
- Почему LLM не справляется с совсем простой обработкой строк, например не может развернуть строку задом наперёд? Токенизация.
- Почему LLM хуже работает на других языках, кроме английского (например, на японском)? Токенизация.
- Почему LLM плохо считает в простой арифметике? Токенизация.
- Почему у GPT-2 было больше проблем с кодом на Python, чем следовало бы? Токенизация.
- Почему моя LLM внезапно замолкает, когда видит строку
<|endoftext|>? Токенизация. - Что это за странное предупреждение про «пробел в конце» (trailing whitespace)? Токенизация.
- Почему LLM ломается, если спросить её про «SolidGoldMagikarp»? Токенизация.
- Почему с LLM лучше использовать YAML, а не JSON? Токенизация.
- Почему LLM на самом деле не является языковым моделированием «от начала до конца» (end-to-end)? Токенизация.
- Что же на самом деле корень всех страданий? Токенизация.
Мы вернёмся к этим вопросам в конце видео.
Токенизация наглядно
Заголовок раздела «Токенизация наглядно»Теперь откроем веб-приложение для токенизации. Оно удобно тем, что токенизация
выполняется прямо в браузере: вы вводите строку слева и сразу видите токены справа. Вверху видно, что сейчас выбран
токенизатор gpt2, и вставленная строка-пример разбивается на 300 токенов. Каждый токен подсвечен своим цветом.
Рисунок в оригинале (assets/tiktokenizer.png): окно tiktokenizer. Слева — текст-пример (английские фразы, пример сложения чисел, фраза на корейском, фрагмент кода на Python), справа — тот же текст, раскрашенный по токенам, и список номеров токенов.
Например, строка «Tokenization» закодировалась в токен 30642, за которым идёт токен 1634. Токен « is» (обратите внимание: это три символа, включая пробел впереди, и это важно!) имеет номер 318. Будьте внимательны с пробелами: они обязательно есть в строке и токенизируются вместе со всеми остальными символами, но для наглядности их обычно не показывают. Отображение пробелов можно включить внизу приложения. Точно так же токен « at» — это 379, « the» — 262 и так далее.
Дальше у нас простой пример арифметики. Здесь видно, что числа токенизатор может разбивать непоследовательно. Например, число 127 — это один токен из трёх символов, а число 677 превращается в два токена: « 6» (снова обратите внимание на пробел впереди!) и «77». Мы полагаемся на то, что языковая модель разберётся в этом произволе. Ей приходится выучить в своих параметрах, во время обучения, что эти два токена (« 6» и «77») вместе образуют число 677. Точно так же, если LLM захочет предсказать, что результат сложения — число 804, ей придётся выдать его за два шага по времени: сначала токен « 8», затем токен «04». Все эти разбиения выглядят совершенно произвольными. В примере чуть ниже 1275 — это «12» и «75», 6773 — на самом деле два токена « 6» и «773», а 8041 — « 8» и «041».
(продолжение следует…)
(В оригинале здесь пометка автора: «TODO: возможно, продолжу, если не придумаем, как генерировать это автоматически
из видео :)». На этом текст lecture.md заканчивается.)
Часть 2. Продолжение лекции (пересказ видео)
Заголовок раздела «Часть 2. Продолжение лекции (пересказ видео)»Строки, Unicode и UTF-8 (14:56–22:47)
Заголовок раздела «Строки, Unicode и UTF-8 (14:56–22:47)»Строка в Python — это неизменяемая последовательность кодовых точек Unicode. Стандарт Unicode описывает около
150 тысяч символов из 161 письменности (на момент записи действовала версия 15.1 от сентября 2023 года). Номер
кодовой точки даёт функция ord: ord('h') равно 104, ord('п') — 1087, ord('👋') — 128075.
Почему бы не взять эти номера прямо как токены? Карпатый называет две причины: словарь вышел бы огромным (около 150 тысяч элементов), а сам стандарт постоянно меняется — это нестабильное представление.
Поэтому текст сначала переводят в байты одной из кодировок Unicode: UTF-8, UTF-16 или UTF-32. UTF-8 кодирует каждую кодовую точку одним–четырьмя байтами, совместима со старой ASCII и господствует в интернете. UTF-16 и UTF-32 на английском тексте расточительны: в байтовом потоке видно много нулей. Посмотрим на русские буквы и эмодзи:
print(list("привет".encode("utf-8")))# [208, 191, 209, 128, 208, 184, 208, 178, 208, 181, 209, 130] — 6 букв, 12 байтовprint(list("👋".encode("utf-8")))# [240, 159, 145, 139] — 1 эмодзи, 4 байтаЕсли брать сами байты как токены, словарь будет всего из 256 элементов, а последовательности — очень длинными. Контекст трансформера конечен, и модель «увидит» слишком мало текста. Нужно сжатие с настраиваемым размером словаря. Его и даёт BPE поверх байтов UTF-8.
Мечта без токенизации (22:47). Карпатый признаётся, что с радостью подавал бы в модель сырые байты. Есть работа 2023 года с иерархическим трансформером, который читает байты напрямую, но, по его мнению, такой подход ещё не проверен достаточным числом групп на достаточном масштабе.
BPE на игрушечном примере (23:50)
Заголовок раздела «BPE на игрушечном примере (23:50)»Пример из Википедии. Есть строка aaabdaaabac из 11 символов и словарь из четырёх: a, b, c, d.
- Самая частая пара —
aa. Заводим новый символZ = aaи заменяем:ZabdZabac— 9 символов, словарь из 5. - Самая частая пара теперь
ab:Y = ab, получаемZYdZYac— 7 символов, словарь из 6. - Самая частая пара
ZY:X = ZY, получаемXdXac— 5 символов, словарь из 7.
Каждый шаг укорачивает последовательность и добавляет один элемент в словарь. Для текста всё то же самое, только стартуем с 256 байтов, а новые токены получают номера 256, 257, 258…
Реализация: подсчёт пар, слияние, обучение (27:02–39:20)
Заголовок раздела «Реализация: подсчёт пар, слияние, обучение (27:02–39:20)»Карпатый берёт абзац из статьи-блога о Unicode: 533 кодовые точки превращаются в 616 байтов (не-ASCII символы
занимают несколько байтов). Самая частая пара в нём — (101, 32), то есть «e» и пробел, 20 раз: многие английские
слова кончаются на «e». После замены этой пары на токен 256 длина падает с 616 до 596 — ровно на 20.
Вот функции из minbpe (minbpe/base.py и minbpe/basic.py, MIT), собранные в один файл. Код рабочий, его можно
запустить как есть:
def get_stats(ids, counts=None): """Считает, сколько раз встречается каждая пара соседних чисел. Пример: [1, 2, 3, 1, 2] -> {(1, 2): 2, (2, 3): 1, (3, 1): 1}""" counts = {} if counts is None else counts for pair in zip(ids, ids[1:]): # перебираем соседние элементы counts[pair] = counts.get(pair, 0) + 1 return counts
def merge(ids, pair, idx): """Заменяет каждое вхождение пары pair в списке ids новым токеном idx. Пример: ids=[1, 2, 3, 1, 2], pair=(1, 2), idx=4 -> [4, 3, 4]""" newids = [] i = 0 while i < len(ids): # не последняя позиция И пара совпала -> заменяем if ids[i] == pair[0] and i < len(ids) - 1 and ids[i+1] == pair[1]: newids.append(idx) i += 2 # перескакиваем через всю пару else: newids.append(ids[i]) i += 1 return newids
def train(text, vocab_size): """Обучение: vocab_size - 256 раз сливаем самую частую пару.""" ids = list(text.encode("utf-8")) # сырые байты: числа 0..255 merges = {} # (int, int) -> int vocab = {idx: bytes([idx]) for idx in range(256)} # int -> bytes for i in range(vocab_size - 256): stats = get_stats(ids) pair = max(stats, key=stats.get) # самая частая пара idx = 256 + i # «чеканим» новый токен ids = merge(ids, pair, idx) merges[pair] = idx vocab[idx] = vocab[pair[0]] + vocab[pair[1]] return merges, vocabОбратите внимание на условие i < len(ids) - 1 в merge: без него на последнем элементе списка мы вышли бы за его
границу. И на то, что новые токены сами могут участвовать в следующих слияниях. Поэтому слияния образуют не одно
дерево, а «лес» над 256 листьями-байтами.
Размер словаря — гиперпараметргиперпараметрEN hyperparameterНастройка, которую не учат градиентом, а выбирают по валидации — степень полинома, λ, learning rate, число слоёв.: чем больше слияний, тем больше словарь и тем короче последовательности. У GPT-4 около 100 тысяч токенов. В видео Карпатый обучает словарь на 276 токенов (20 слияний) на полном тексте той же статьи: 24 тысячи байтов сжимаются примерно до 19 тысяч токенов, степень сжатиястепень сжатия токенизатораEN compression ratio (characters per token)Сколько символов (или байтов) текста приходится в среднем на один токен. Чем выше — тем «дешевле» язык для модели. ≈ 1,27.
Токенизатор — отдельный этап (39:20). Это главная мысль раздела. Токенизатор обучается отдельно от LLM, на своём наборе данных и один раз. Потом весь корпус для LLM прогоняют через него, сохраняют токены на диск, а сырой текст модели уже не нужен.
flowchart LR A["Обучающий набор токенизатора"] -->|"BPE: обучение"| T["Токенизатор: vocab + merges"] S["Сырой текст: кодовые точки Unicode"] -->|"encode"| T T -->|"decode"| S T -->|"последовательность токенов"| L["LLM (обучается отдельно, на своём наборе)"]
От состава обучающего набора токенизатора зависит, какие языки будут «плотными». Если в нём много японского, для японского появится больше слияний, японский текст станет короче в токенах, и LLM уместит в контекст больше такого текста. Для нас это объясняет, почему русский и китайский у многих моделей «дороже» английского (подробнее — в уроке 4.2.1).
Декодирование и кодирование (42:47–57:36)
Заголовок раздела «Декодирование и кодирование (42:47–57:36)»Декодирование. Строим vocab: номер токена → байты (первые 256 — сами байты, остальные — склейка байтов двух
«детей» в порядке слияний; в Python начиная с 3.7 словари хранят порядок вставки, и это здесь важно). Затем склеиваем
байты всех токенов и декодируем UTF-8.
Подвох: не любая последовательность байтов — корректный UTF-8. Например, одиночный байт 128 (в двоичной записи
10000000) не может начинать символ:
bytes([128]).decode("utf-8")# UnicodeDecodeError: 'utf-8' codec can't decode byte 0x80 in position 0: invalid start bytebytes([128]).decode("utf-8", errors="replace")# '�' — символ заменыМодель вполне может сгенерировать такую «неудачную» последовательность токенов. Поэтому стандартная практика, как и в
коде OpenAI, — errors="replace". Если в выводе LLM вы видите символ �, значит, модель выдала некорректную
последовательность.
Кодирование. Переводим текст в байты и повторяем: находим среди соседних пар ту, у которой самый ранний номер слияния (её надо сливать первой, потому что поздние слияния опираются на ранние), и сливаем. Если ни одна пара не встречается в таблице слияний, останавливаемся. Отдельно обрабатывается случай строки из одного символа или пустой: пар нет, сливать нечего.
def decode(ids, vocab): """Токены -> строка. errors="replace" спасает от «битого» UTF-8.""" text_bytes = b"".join(vocab[idx] for idx in ids) return text_bytes.decode("utf-8", errors="replace")
def encode(text, merges): """Строка -> токены. Сливаем пары в том же порядке, что при обучении.""" ids = list(text.encode("utf-8")) while len(ids) >= 2: stats = get_stats(ids) # пара с самым ранним номером слияния; не слияние -> бесконечность pair = min(stats, key=lambda p: merges.get(p, float("inf"))) if pair not in merges: break # сливать больше нечего ids = merge(ids, pair, merges[pair]) return ids
text = "мама мыла раму, мама мыла раму, а папа мыл машину"merges, vocab = train(text, 256 + 10)for (a, b), idx in merges.items(): print(idx, vocab[idx].decode("utf-8", errors="replace"))ids = encode(text, merges)print(len(text.encode("utf-8")), "байт ->", len(ids), "токенов")print(decode(ids, vocab) == text)Вывод (мы запустили этот код):
256 а257 м258 а259 м�260 ма261 а м�262 а мы263 а мы�264 а мыл265 мам87 байт -> 37 токеновTrueПервые слияния склеивают два байта русской буквы: «а» в UTF-8 — это пара (208, 176). А токены вроде м� — это
буква «м» плюс первый байт следующей буквы: BPE ничего не знает о границах символов, он видит только байты.
Карпатый проверяет, что decode(encode(text)) == text и на обучающем тексте, и на тексте, которого токенизатор не
видел. Обратное верно не всегда: encode(decode(ids)) может не вернуть исходные ids, ведь не каждая
последовательность токенов декодируется.
Регулярное выражение GPT-2 (57:36)
Заголовок раздела «Регулярное выражение GPT-2 (57:36)»Наивный BPE на большом корпусе наделает токенов вроде «dog.», «dog!», «dog?» — смысл склеится с пунктуацией. В GPT-2 это запрещают сверху: текст сначала режут регулярным выражением на куски, BPE работает внутри каждого куска отдельно, а результаты просто склеиваются. Так слияния никогда не пересекают границы категорий. В уроке курса это называется предразбиениемпредразбиение (претокенизация)EN pre-tokenizationПредварительная нарезка текста регулярным выражением на слова, числа и пунктуацию; BPE-слияния действуют только внутри кусков..
Шаблон GPT-2 (из encoder.py OpenAI; там import regex as re — это пакет regex, более мощный, чем встроенный re):
import regex as regpt2pat = re.compile(r"""'s|'t|'re|'ve|'m|'ll|'d| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+""")print(re.findall(gpt2pat, "Hello world123 how's are you!!!?"))# ['Hello', ' world', '123', ' how', "'s", ' ', ' are', ' you', '!!!?']Как читать шаблон (альтернативы перебираются слева направо):
's|'t|'re|'ve|'m|'ll|'d— частые английские сокращения с апострофом. Карпатому это не нравится: с юникодным апострофом’правило не срабатывает, а с заглавными буквами ('S) тоже нет — в коде GPT-2 даже есть комментарий, что надо было добавитьre.IGNORECASE. К тому же это правило заточено под английский.?\p{L}+— необязательный пробел и буквы любого языка;?\p{N}+— необязательный пробел и цифры.?[^\s\p{L}\p{N}]+— всё, что не буква, не цифра и не пробел, то есть пунктуация.\s+(?!\S)— пробелы, кроме последнего (негативный просмотр вперёд). Последний пробел достаётся следующему слову, и получается привычный токен вида « you». Финальный\s+ловит хвостовые пробелы.
Тонкость: в GPT-2 пробелы в коде на Python никогда не сливаются (каждый пробел — токен 220), хотя одно только регулярное выражение этого не объясняет. Значит, при обучении были дополнительные правила. Какие — неизвестно: код обучения токенизатора GPT-2 OpenAI не публиковала, есть только код применения.
tiktoken и шаблон GPT-4 (1:11:38)
Заголовок раздела «tiktoken и шаблон GPT-4 (1:11:38)»tiktoken — официальная библиотека OpenAI. Она умеет только кодировать и
декодировать, обучать не умеет. Шаблоны всех токенизаторов лежат в её файле tiktoken_ext/openai_public.py. У GPT-4
(cl100k_base) шаблон изменился:
'(?i:[sdmt]|ll|ve|re)|[^\r\n\p{L}\p{N}]?+\p{L}+|\p{N}{1,3}| ?[^\s\p{L}\p{N}]++[\r\n]*|\s*[\r\n]|\s+(?!\S)|\s+Главные отличия: сокращения теперь ловятся без учёта регистра ((?i:...)); пробелы обрабатываются иначе и
сливаются (код на Python стал заметно короче в токенах); цифры объединяются не больше чем по три (\p{N}{1,3}),
чтобы не было токенов из длинных чисел. Словарь вырос примерно с 50 до 100 тысяч. Зачем сделано каждое изменение,
OpenAI не документировала.
Код токенизатора GPT-2 (1:14:59)
Заголовок раздела «Код токенизатора GPT-2 (1:14:59)»encoder.py из репозитория GPT-2 загружает два файла: encoder.json — это наш vocab (только «перевёрнутый»:
строка → номер), и vocab.bpe — это наши merges. Этих двух объектов достаточно, чтобы кодировать и декодировать.
Ещё там есть слой byte_encoder / byte_decoder, который Карпатый называет случайной деталью реализации, не
несущей смысла. Сердце файла, функция bpe, — тот же цикл: найти пару для слияния, слить, повторить.
Спецтокены (1:18:26)
Заголовок раздела «Спецтокены (1:18:26)»Почему у GPT-2 в словаре 50 257 токенов? 256 байтов + 50 000 слияний = 50 256, и ещё один
спецтокенспециальный токен (спецтокен)EN special tokenУправляющий токен, а не кусок текста — начало/конец текста (BOS/EOS), заполнитель для батча (PAD), разметка ролей в чате (например, <|im_start|> у Qwen). <|endoftext|> с номером 50256. Его вставляют между документами в
обучающих данных, и модель учится понимать: после него идёт что-то не связанное с предыдущим.
Спецтокены не проходят через BPE: в tiktoken (код на Rust) для них отдельная обработка — найденная строка
<|endoftext|> сразу заменяется своим номером. В чат-моделях спецтокенов больше: например, у GPT-3.5-turbo каждое
сообщение обрамляют <|im_start|> и <|im_end|>. У GPT-4 к <|endoftext|> добавились токены FIM («fill in the
middle», заполнение середины) — <|fim_prefix|>, <|fim_middle|>, <|fim_suffix|> — и ещё <|endofprompt|>.
Добавить спецтокен в уже обученную модель можно, но нужна небольшая «хирургия»: добавить строку в таблицу
эмбеддингов (инициализировать малыми случайными числами) и расширить выходной слой lm_head, чтобы модель могла
предсказывать новый токен. Так делают при дообучении базовой модели в чат-модель (урок
4.2.3, раздел о chat-шаблонах).
Упражнения minbpe (1:25:28)
Заголовок раздела «Упражнения minbpe (1:25:28)»Здесь Карпатый показывает репозиторий minbpe и файл упражнений — их перевод ниже, в части 3. В README minbpe есть сравнение: первые слияния GPT-4 и слияния токенизатора, обученного на статье Википедии о Тейлор Свифт. Они похожи, потому что алгоритм один и тот же; разница — от обучающих данных. Самое первое слияние GPT-4 — два пробела в один токен 256. Отсюда Карпатый предполагает, что в обучающем наборе токенизатора GPT-4 было много кода на Python.
SentencePiece (1:28:42)
Заголовок раздела «SentencePiece (1:28:42)»SentencePiece от Google умеет и обучать, и применять токенизатор, и
поэтому популярен: на нём сделаны словари Llama и Mistral. Главное отличие — порядок операций. tiktoken сначала
переводит текст в байты UTF-8 и сливает байты. SentencePiece сливает сами кодовые точки. Редкие кодовые точки
(редкость задаёт параметр character_coverage) либо превращаются в неизвестный токен <unk>, либо, если включён
byte_fallback, раскладываются на байты UTF-8, для которых в словаре есть 256 отдельных токенов.
Карпатый воспроизводит настройки токенизатора Llama 2 (их можно прочитать из файла tokenizer.model, который
опубликовала Meta) на игрушечном словаре из 400 токенов и показывает:
- порядок словаря: спецтокены (
<unk>= 0,<s>= 1,</s>= 2), затем 256 байтовых токенов, затем слияния, затем отдельные кодовые точки; - корейского в обучающем тексте не было, поэтому «안녕하세요» кодируется байтовыми токенами (номера байтов сдвинуты на 3
из-за спецтокенов); если выключить
byte_fallback, вся фраза превращается в один<unk>— для модели это потеря информации, поэтому Llama правильно его включила; add_dummy_prefix=Trueдобавляет пробел в начало текста, чтобы «world» в начале и « world» в середине предложения стали одним токеном; пробелы SentencePiece показывает символом▁.
Вывод Карпатого: у SentencePiece много исторического багажа (например, понятие «предложения» и его максимальной длины), сотни опций, в которых легко ошибиться, и слабая документация.
Как выбрать размер словаря (1:43:27)
Заголовок раздела «Как выбрать размер словаря (1:43:27)»В модели GPT из прошлой лекции vocab_size встречается ровно в двух местах: в таблице эмбеддингов (строка на каждый
токен) и в последнем линейном слое lm_head (по скалярному произведению на каждый токен). Почему словарь нельзя
сделать бесконечным:
- растут таблица эмбеддингов и
lm_head, а с ними вычисления; - редкие токены почти не встречаются в данных, и их векторы остаются недообученными;
- в один токен сжимается слишком много текста, и модели «не хватает времени подумать» над ним за один проход.
Плюс большого словаря — короткие последовательности. На практике выбирают эмпирически: у современных моделей — от нескольких десятков тысяч до примерно ста тысяч токенов.
Новые токены и сжатие промпта (1:48:11)
Заголовок раздела «Новые токены и сжатие промпта (1:48:11)»Добавлять токены в обученную модель — обычная операция: расширяем эмбеддинги и lm_head, часто замораживаем
остальную модель и обучаем только новые параметры. Пример более смелой идеи — статья о «gist-токенах»: длинный
промпт «сжимают» в несколько новых токенов, обучая только их эмбеддинги так, чтобы модель с ними вела себя как с
полным промптом (дистилляция). Это один из методов параметрически эффективного дообученияпараметрически-эффективное дообучение (PEFT)EN parameter-efficient fine-tuning (PEFT)Обучается малая доля параметров (адаптеры, LoRA, префиксы), основная модель заморожена. Так же называется библиотека Hugging Face..
Токены для картинок, видео и звука (1:49:58)
Заголовок раздела «Токены для картинок, видео и звука (1:49:58)»Похоже, сообщество сходится к тому, чтобы не менять трансформер, а токенизировать другие модальности. Картинку можно превратить в последовательность целых чисел (жёсткие токены) или в векторы, прошедшие через узкое место, как в автокодировщике (мягкие токены). В отчёте о Sora OpenAI сравнивает текстовые токены LLM с «визуальными патчами».
Странности LLM — откуда они берутся (1:51:41)
Заголовок раздела «Странности LLM — откуда они берутся (1:51:41)»Карпатый возвращается к списку из начала лекции:
| Странность | Объяснение из лекции |
|---|---|
| Не может посчитать буквы | .DefaultCellStyle — один токен GPT-4. На вопрос, сколько в нём букв «l», модель ответила «3», а их 4 |
| Не может развернуть строку | GPT-4 выдал мешанину. Если попросить сначала выписать символы через пробел, а потом развернуть список, получается правильно: каждый символ становится отдельным токеном |
| Хуже работает на других языках | Меньше данных и при обучении модели, и при обучении токенизатора. «Hello how are you» — 5 токенов, перевод на корейский — 15; даже «안녕하세요» — 3 токена |
| Плохо считает | Числа разбиты на токены произвольно (четырёхзначное число может быть одним токеном или двумя в любой комбинации). Llama 2 поэтому разбивает числа на отдельные цифры |
| GPT-2 плохо пишет на Python | Каждый пробел отступа — отдельный токен, контекст тратится впустую. В GPT-4 исправлено |
| Замолкает на строке endoftext (спецтокен GPT) | Похоже, где-то строку из ввода пользователя разобрали как спецтокен. Знание спецтокенов становится поверхностью атаки |
| Предупреждение «trailing whitespace» | Пробел обычно приклеен к началу следующего токена (« o» — токен 8840). Отдельный пробел в конце промпта (токен 220) почти не встречался при обучении — модель вне распределения |
| Ломается на «SolidGoldMagikarp» | Это ник пользователя Reddit: он был частым в данных токенизатора и получил свой токен, но в данных LLM почти не встречался. Строка эмбеддинга осталась необученной — как «невыделенная память» в программе на C |
| YAML лучше JSON | Те же данные: 116 токенов в JSON против 99 в YAML. Вы платите за каждый токен — деньгами и контекстом |
Отдельно он упоминает «нестабильные токены»: если промпт обрывается на середине привычного токена, модель тоже выходит за пределы распределения. В коде tiktoken на Rust много недокументированной обработки таких случаев.
Итоговые рекомендации (2:10:20)
Заголовок раздела «Итоговые рекомендации (2:10:20)»- Если можете, переиспользуйте словарь GPT-4 и библиотеку tiktoken: она быстрая, а byte-level BPE — чистый подход.
- Если нужен свой словарь, берите BPE в SentencePiece, но очень аккуратно с настройками: лучше скопировать настройки Meta для Llama 2.
- Идеал, которого на момент записи не было, — «tiktoken, но с кодом обучения». minbpe — его реализация, пока на
медленном Python. (Позже в nanochat Карпатый собрал именно такую связку: обучение на Rust в
rustbpe, применение через tiktoken.)
И напутствие: токенизация скучна и раздражает, но отмахиваться от неё нельзя — здесь много острых углов, включая вопросы безопасности. А того, кто избавит LLM от токенизации, ждёт «вечная слава».
Часть 3. Упражнение: свой токенизатор GPT-4 (перевод exercise.md)
Заголовок раздела «Часть 3. Упражнение: свой токенизатор GPT-4 (перевод exercise.md)»Постройте свой токенизатор GPT-4!
Напишите класс BasicTokenizer с тремя основными функциями:
def train(self, text, vocab_size, verbose=False)def encode(self, text)def decode(self, ids)
Обучите токенизатор на любом тексте и посмотрите на получившиеся слияния. Выглядят ли они разумно? В качестве
стандартного теста можно взять файл tests/taylorswift.txt.
Превратите BasicTokenizer в RegexTokenizer, который принимает шаблон регулярного выражения и разбивает текст в
точности как GPT-4. Обрабатывайте куски по отдельности, как раньше, а затем склеивайте результаты. Переобучите
токенизатор и сравните результаты до и после. Вы должны увидеть, что теперь ни один токен не пересекает границы
категорий (цифры, буквы, пунктуация, больше одного пробела). Используйте шаблон GPT-4:
GPT4_SPLIT_PATTERN = r"""'(?i:[sdmt]|ll|ve|re)|[^\r\n\p{L}\p{N}]?+\p{L}+|\p{N}{1,3}| ?[^\s\p{L}\p{N}]++[\r\n]*|\s*[\r\n]|\s+(?!\S)|\s+"""Теперь можно загрузить слияния токенизатора GPT-4 и показать, что ваш токенизатор даёт идентичные результаты и для
encode, и для decode, совпадая с tiktoken.
# добейтесь совпадения с этимimport tiktokenenc = tiktoken.get_encoding("cl100k_base") # это токенизатор GPT-4ids = enc.encode("hello world!!!? (안녕하세요!) lol123 😉")text = enc.decode(ids) # получаем тот же текст обратноК сожалению, вы столкнётесь с двумя проблемами:
- Восстановить исходные слияния из токенизатора GPT-4 нетривиально. Легко получить то, что здесь называется
vocab, а у них хранится вenc._mergeable_ranks. Можете скопировать функциюrecover_mergesизminbpe/gpt4.py: она принимает эти ранги и возвращает исходные слияния. Если хотите понять, как она работает, прочитайте это и это. Суть в том, что при некоторых условиях достаточно хранить только родительские узлы (и их ранг) и не хранить, какие именно дети слились в каждого родителя. - Кроме того, токенизатор GPT-4 почему-то переставляет свои сырые байты. Эта перестановка хранится в первых 256
элементах
mergeable_ranks, поэтому восстановить её относительно просто:byte_shuffle = {i: enc._mergeable_ranks[bytes([i])] for i in range(256)}. И вencode, и вdecodeбайты придётся соответственно переставлять. Если застрянете, подсказки — в файлеminbpe/gpt4.py.
(Необязательно, раздражает, польза неочевидна.) Добавьте обработку спецтокенов. Тогда вы сможете совпасть с tiktoken даже при наличии спецтокенов, например:
import tiktokenenc = tiktoken.get_encoding("cl100k_base") # это токенизатор GPT-4ids = enc.encode("<|endoftext|>hello world", allowed_special="all")Без allowed_special tiktoken выдаст ошибку.
Если вы дошли досюда, вы уже профи в токенизации LLM! К сожалению, это ещё не всё: многие LLM вне OpenAI (например, Llama, Mistral) используют вместо этого sentencepiece. Главное отличие: sentencepiece запускает BPE прямо на кодовых точках Unicode, а не на байтах UTF-8. Можете исследовать sentencepiece самостоятельно (удачи, там не очень красиво), а если у вас по-настоящему много свободного времени, в качестве задачи со звёздочкой перепишите свой BPE так, чтобы он работал на кодовых точках, и добейтесь совпадения с токенизатором Llama 2.
Часть 4. README репозитория minbpe (перевод)
Заголовок раздела «Часть 4. README репозитория minbpe (перевод)»Минимальный, чистый код алгоритма (byte-level) Byte Pair Encoding (BPE), который обычно используют для токенизации в LLM. Алгоритм называется «byte-level», потому что работает со строками в кодировке UTF-8.
Этот алгоритм стал популярен в LLM благодаря статье о GPT-2 и сопровождающему её коду от OpenAI. Первоисточником использования BPE в задачах NLP считают Sennrich et al. 2015. Сегодня все современные LLM (например, GPT, Llama, Mistral) обучают свои токенизаторы этим алгоритмом.
В репозитории два токенизатора. Оба умеют три основные функции токенизатора: 1) обучить словарь и слияния на данном тексте, 2) закодировать текст в токены, 3) декодировать токены в текст. Файлы репозитория:
- minbpe/base.py: класс
Tokenizer, базовый. В нём заготовкиtrain,encodeиdecode, сохранение и загрузка и несколько общих вспомогательных функций. Этот класс не предназначен для прямого использования — от него наследуются. - minbpe/basic.py:
BasicTokenizer— простейшая реализация алгоритма BPE, которая работает прямо на тексте. - minbpe/regex.py:
RegexTokenizer, который дополнительно разбивает входной текст по шаблону регулярного выражения. Это этап предобработки: перед токенизацией текст делится по категориям (например, буквы, цифры, пунктуация), и слияния не пересекают границ категорий. Приём появился в статье о GPT-2 и используется до сих пор, включая GPT-4. Этот класс также обрабатывает спецтокены, если они есть. - minbpe/gpt4.py:
GPT4Tokenizer. Этот класс — лёгкая обёртка надRegexTokenizer(пункт 2 выше; в оригинале номер указан так), которая в точности воспроизводит токенизацию GPT-4 из библиотеки tiktoken. Обёртка решает некоторые детали: восстановление точных слияний токенизатора и обработку досадных (и, вероятно, исторических?) перестановок однобайтовых токенов.
Наконец, скрипт train.py обучает два основных токенизатора на входном тексте tests/taylorswift.txt (это статья Википедии о ней, хе-хе) и сохраняет словарь на диск для просмотра. На моём MacBook (M1) скрипт работает около 25 секунд.
Все файлы выше очень короткие и подробно прокомментированы, а в конце каждого есть пример использования.
Быстрый старт
Заголовок раздела «Быстрый старт»Простейший пример — воспроизведём статью Википедии о BPE:
from minbpe import BasicTokenizertokenizer = BasicTokenizer()text = "aaabdaaabac"tokenizer.train(text, 256 + 3) # 256 — это байтовые токены, затем делаем 3 слиянияprint(tokenizer.encode(text))# [258, 100, 258, 97, 99]print(tokenizer.decode([258, 100, 258, 97, 99]))# aaabdaaabactokenizer.save("toy")# записывает два файла: toy.model (для загрузки) и toy.vocab (для просмотра)Согласно Википедии, BPE на строке «aaabdaaabac» за 3 слияния даёт строку «XdXac», где X=ZY, Y=ab и Z=aa. Тонкость в том, что minbpe всегда отводит под токены 256 отдельных байтов, а дальше сливает байты по мере надобности. Поэтому у нас a=97, b=98, c=99, d=100 (их значения в ASCII). Когда (a,a) сливается в Z, Z становится 256. Аналогично Y станет 257, а X — 258. Итак, мы начинаем с 256 байтов и делаем 3 слияния, чтобы получить результат выше с ожидаемым выводом [258, 100, 258, 97, 99].
Применение: сравнение с GPT-4
Заголовок раздела «Применение: сравнение с GPT-4»Проверим, что RegexTokenizer по возможностям не уступает токенизатору GPT-4 из
tiktoken:
text = "hello123!!!? (안녕하세요!) 😉"
# tiktokenimport tiktokenenc = tiktoken.get_encoding("cl100k_base")print(enc.encode(text))# [15339, 4513, 12340, 30, 320, 31495, 230, 75265, 243, 92245, 16715, 57037]
# нашfrom minbpe import GPT4Tokenizertokenizer = GPT4Tokenizer()print(tokenizer.encode(text))# [15339, 4513, 12340, 30, 320, 31495, 230, 75265, 243, 92245, 16715, 57037](Для запуска понадобится pip install tiktoken.) Под капотом GPT4Tokenizer — лишь лёгкая обёртка над
RegexTokenizer, которой передаются слияния и спецтокены GPT-4. Можно убедиться, что и спецтокены обрабатываются
правильно:
text = "<|endoftext|>hello world"
# tiktokenimport tiktokenenc = tiktoken.get_encoding("cl100k_base")print(enc.encode(text, allowed_special="all"))# [100257, 15339, 1917]
# нашfrom minbpe import GPT4Tokenizertokenizer = GPT4Tokenizer()print(tokenizer.encode(text, allowed_special="all"))# [100257, 15339, 1917]Обратите внимание: как и в tiktoken, при вызове encode нужно явно заявить, что вы хотите использовать и распознавать
спецтокены. Иначе это может стать серьёзной ловушкой: можно нечаянно токенизировать данные, которые контролирует
атакующий (например, промпты пользователей), вместе со спецтокенами. Параметр allowed_special может быть равен
“all”, “none” или списку разрешённых спецтокенов.
Обучение
Заголовок раздела «Обучение»В отличие от tiktoken, этот код позволяет обучить свой токенизатор. В принципе и насколько я знаю, если обучить
RegexTokenizer на большом наборе данных с размером словаря 100K, вы воспроизведёте токенизатор GPT-4.
Есть два пути. Первый: вы решаете, что вам не нужна сложность разбиения и предобработки текста регулярными
выражениями, и спецтокены вас тоже не интересуют. Тогда берите BasicTokenizer. Его можно обучить, а потом
кодировать и декодировать, например так:
from minbpe import BasicTokenizertokenizer = BasicTokenizer()tokenizer.train(very_long_training_string, vocab_size=4096)tokenizer.encode("hello world") # строка -> токеныtokenizer.decode([1000, 2000, 3000]) # токены -> строкаtokenizer.save("mymodel") # записывает mymodel.model и mymodel.vocabtokenizer.load("mymodel.model") # загружает модель обратно; vocab — только для просмотраЕсли же вы хотите повторить то, что OpenAI сделала для своего текстового токенизатора, разумно перенять их подход с
разбиением текста по категориям шаблоном регулярного выражения. Шаблон GPT-4 стоит в RegexTokenizer по умолчанию,
так что достаточно чего-то вроде:
from minbpe import RegexTokenizertokenizer = RegexTokenizer()tokenizer.train(very_long_training_string, vocab_size=32768)tokenizer.encode("hello world") # строка -> токеныtokenizer.decode([1000, 2000, 3000]) # токены -> строкаtokenizer.save("tok32k") # записывает tok32k.model и tok32k.vocabtokenizer.load("tok32k.model") # загружает модель с дискаРазумеется, размер словаря стоит менять в зависимости от размера ваших данных.
Спецтокены. Наконец, вы можете захотеть добавить в токенизатор спецтокены. Зарегистрируйте их функцией
register_special_tokens. Например, если вы обучали со словарём vocab_size = 32768, то первые 256 токенов —
байтовые, следующие 32768−256 — токены слияний, а после них можно добавить спецтокены. Последний «настоящий» токен
слияния будет иметь номер 32767 (vocab_size − 1), так что ваш первый спецтокен должен идти сразу за ним, с номером
ровно 32768. Итак:
from minbpe import RegexTokenizertokenizer = RegexTokenizer()tokenizer.train(very_long_training_string, vocab_size=32768)tokenizer.register_special_tokens({"<|endoftext|>": 32768})tokenizer.encode("<|endoftext|>hello world", allowed_special="all")Конечно, после этого можно добавить и другие токены, сколько захотите. И последнее: я очень старался, чтобы сам код был чистым, читаемым и легко изменяемым. Не бойтесь читать код и разбираться, как он работает. Тесты — тоже хорошее место, где искать примеры использования. Кстати, о них:
Для тестов мы используем библиотеку pytest. Все они лежат в каталоге tests/. Сначала выполните
pip install pytest, если ещё не сделали этого, а затем:
$ pytest -v .чтобы запустить тесты. (-v — подробный вывод, чуть красивее.)
Расширения от сообщества
Заголовок раздела «Расширения от сообщества»- gnp/minbpe-rs: реализация
minbpeна Rust, (почти) один к одному соответствующая версии на Python.
Упражнение
Заголовок раздела «Упражнение»Для тех, кто изучает BPE, — рекомендуемая последовательность упражнений, как шаг за шагом построить свой minbpe. См. exercise.md (перевод — часть 3 этой страницы).
Код этого репозитория я написал в этом видео на YouTube. Лекция есть и в текстовом виде — в lecture.md (перевод — часть 1 этой страницы).
Что сделать (todo)
Заголовок раздела «Что сделать (todo)»- написать более оптимизированную версию на Python, которая справится с большими файлами и большими словарями;
- написать ещё более оптимизированную версию на C или Rust (продумать);
- переименовать GPT4Tokenizer в GPTTokenizer и поддержать также GPT-2/GPT-3/GPT-3.5?
- написать LlamaTokenizer по аналогии с GPT4Tokenizer (то есть попытаться сделать эквивалент sentencepiece).
Лицензия
Заголовок раздела «Лицензия»MIT
Что сделать руками
Заголовок раздела «Что сделать руками»- Запустите код из части 2 на своём тексте — на русском и на китайском. Посмотрите, какие слияния идут первыми и
сколько среди них «обрывков» символов вида
м�. - Пройдите шаги 1–3 упражнения из части 3. Для шага 3 понадобятся
pip install tiktoken regex. Сверяйтесь с файламиminbpe/regex.pyиminbpe/gpt4.py, когда застрянете, и запускайте тестыpytest -v .. - Сравните на одной русской фразе токены
gpt2иcl100k_baseв tiktokenizer: сколько токенов в каждом случае и почему. - Повторите опыт Карпатого с JSON и YAML: возьмите одну и ту же структуру и посчитайте токены.
- Задание в браузере по BPE — в уроке 4.2.2.
Словарик
Заголовок раздела «Словарик»| EN | RU |
|---|---|
| tokenizer, tokenization | токенизатор, токенизация |
| token, vocabulary (vocab) | токен, словарь |
| Byte Pair Encoding (BPE), byte-level BPE | кодирование пар байтов, BPE на уровне байтов |
| merge, merges | слияние, таблица слияний |
| code point | кодовая точка (Unicode) |
| UTF-8 encoding | кодировка UTF-8 |
| compression ratio | степень сжатия |
| regex split pattern, pre-tokenization | шаблон разбиения, предразбиение |
| special token | спецтокен |
| byte fallback | откат к байтам |
embedding table, lm_head | таблица эмбеддингов, выходной слой |
| model surgery | «хирургия» модели: расширение эмбеддингов и выходного слоя |
| trailing whitespace | пробел в конце |
| out of distribution | вне распределения обучающих данных |