01 · Версия материала 7
Единицы текста и идентификаторы токенов
Как текст UTF-8 получает ID фиксированного словаря скалярных значений, чем ID отличаются от позиций и почему неизвестные значения теряются.
Сопоставим единицам текста фиксированные ID
Чтобы передать текст модели из главы 0, его нужно преобразовать в последовательность целочисленных идентификаторов (ID) токенов из конечного словаря. Токенизатор выбирает, на какие единицы разбить текст, а словарь сопоставляет этим единицам ID. От выбора единиц зависят состав таблицы и длина последовательности ID. В этом уроке один токен соответствует одному скалярному значению Unicode: так каждое сопоставление легко разобрать.
Прежде чем считать длину, различим три вида единиц:
- Байт — единица хранения из восьми битов. Строки Rust хранят текст в виде байтов UTF-8; одному скалярному значению Unicode может соответствовать несколько таких байтов.
- Скалярное значение Unicode — кодовая точка Unicode, кроме суррогатных
кодовых точек. Кодовые точки задаются числами; тип
charв Rust представляет одно скалярное значение. См. документацию Rust дляchar. - Графемный кластер приближённо соответствует символу, который читатель
воспринимает как единое целое. Он может состоять из нескольких скалярных
значений: в
éзаU+0065следует комбинируемый знак ударенияU+0301, и эти два значения образуют один графемный кластер. Такие границы описывает спецификация Unicode по сегментации текста.
Метод Rust str::chars
перебирает скалярные значения, поэтому учебный пример не выделяет и не считает
графемные кластеры. В записи U+... значение Unicode указано в шестнадцатеричной
системе счисления. В массивах байтов и ID токенов ниже используются десятичные
числа.
Построим таблицу один раз
Единственный обучающий текст в примере — cat кот, включая пробел. При построении
словаря из него собираются различные скалярные значения, сортируются по числовому
значению Unicode и получают ID начиная с 1. ID 0 зарезервирован для маркера
неизвестного токена <UNK>.
| ID токена | Единица в словаре | Запись Unicode |
|---|---|---|
| 0 | <UNK> | не отдельное скалярное значение |
| 1 | пробел | U+0020 |
| 2 | a | U+0061 |
| 3 | c | U+0063 |
| 4 | t | U+0074 |
| 5 | к | U+043A |
| 6 | о | U+043E |
| 7 | т | U+0442 |
Сортировка делает назначение ID воспроизводимым. Это правило данного учебного примера, а не обязательный порядок для всех токенизаторов. Во время кодирования таблица фиксирована: поиск входного значения не обучает словарь и не добавляет записи. Если построить словарь заново по другому тексту, назначения ID могут измениться.
ID служит меткой записи в таблице. Например, для c запись Unicode — U+0063,
байт UTF-8 — [99], а ID в словаре — 3. Эти числа относятся к разным
представлениям. Близость ID по величине сама по себе не означает близости по
смыслу; целочисленные ID не являются обученными эмбеддингами.
Прочитаем входные строки по таблице
Для cat находим сначала c, затем a и t: получаем ID [3, 2, 4].
Для кот находим к, затем о и т: получаем [5, 6, 7]. Кодирование
сохраняет порядок входного текста, хотя сам словарь был отсортирован.
| Представление | cat | кот |
|---|---|---|
| Байты UTF-8 | [99, 97, 116] | [208, 186, 208, 190, 209, 130] |
| Скалярные значения Unicode | [U+0063, U+0061, U+0074] | [U+043A, U+043E, U+0442] |
| ID токенов | [3, 2, 4] | [5, 6, 7] |
| Декодированный текст | cat | кот |
В cat каждому скалярному значению соответствует один байт. В кот значению
к соответствуют байты [208, 186], значению о — [208, 190], а т —
[209, 130]. Каждая группа байтов представляет одно скалярное значение, которому
назначается один ID. Итого в cat — 3 байта, 3 скалярных значения и 3 ID;
в кот — 6 байтов, 3 скалярных значения и 3 ID.
Главная мысль: Объём хранения и длина последовательности токенов измеряются в разных единицах. В этом примере каждому скалярному значению соответствует один ID; другой токенизатор может выбрать другие единицы.
Один поиск для каждой позиции
В разобранных примерах в каждой позиции действует одно и то же правило:
Для входного скалярного значения в позиции поиск возвращает ID
, который занимает ту же позицию в выходной последовательности. Множество
содержит ровно семь известных скалярных значений из cat кот, включая
пробел. Если значения нет в , поиск возвращает зарезервированный ID .
Отсчёт позиций начинается с нуля. В cat индекс обозначает первую позицию:
в ней находится скалярное значение c, а его ID — 3. Позиция указывает, где
находится значение; ID указывает, какую запись словаря оно представляет.
Поскольку на каждое скалярное значение приходится один поиск, число скалярных
значений и число ID совпадают независимо от количества байтов UTF-8.
Каждому известному скалярному значению назначен отдельный ID. Поэтому
декодирование известных ID по порядку восстанавливает исходную последовательность
скалярных значений. Именно так cat и кот сохраняются после кодирования и
декодирования. То же рассуждение применимо к любой последовательности, целиком
составленной из известных скалярных значений этой таблицы.
Входная строка ? показывает границу этой гарантии. Её байты UTF-8 — [63],
а скалярное значение — U+003F, которого нет в таблице. Поэтому кодирование
даёт [0], а декодирование — строку <UNK>. Все неизвестные скалярные значения
получают один ID, и при декодировании нельзя определить, какое именно значение
было заменено. Причина потери — неполный охват словаря, хотя ? является
корректным текстом UTF-8. Передача ID вне таблицы, например 8, — другая
ситуация: существующая функция decode возвращает ошибку.
Обозначения в правиле поиска
| Обозначение | Смысл в этом примере |
|---|---|
| входное скалярное значение Unicode в позиции | |
семь известных скалярных значений из cat кот, включая пробел | |
| поиск по фиксированной таблице: ID известного значения либо , если записи нет | |
| ID токена в той же позиции , что и входное скалярное значение | |
| позиция в последовательности с отсчётом от нуля, отличная от ID | |
зарезервированный ID токена, который декодируется в <UNK> |
Зачем нужны единицы мельче слова
Словарю целых слов нужны отдельные записи для разных форм. Если новой формы нет в фиксированной таблице, она считается неизвестной. Из меньших известных единиц можно составить форму, которую словарь никогда не хранил целиком, но тогда слово из нескольких букв занимает больше позиций в последовательности.
В существующем сравнении на Rust сначала используются пробельные границы:
red fox превращается в ["red", "fox"], а рыжий кот — в ["рыжий", "кот"].
Метод Rust split_whitespace
разбивает текст по пробельным символам, оставляя пунктуацию при словах. Это
простой пример единиц размером со слово, а не полноценное лингвистическое
разбиение на слова.
rust/demos/ch01-text-units/src/lib.rs#historical-splitting /// Demonstrates the historical intuition of whitespace-delimited word units.
///
/// This is a contrast for the lesson, not a general-purpose tokenizer:
/// punctuation stays attached and unseen word forms remain distinct.
pub fn split_words(text: &str) -> Vec<&str> {
text.split_whitespace().collect()
}
/// Demonstrates scalar-level units, historically called character-level units.
pub fn split_scalars(text: &str) -> Vec<char> {
text.chars().collect()
} Для сравнения, split_scalars("кот") возвращает ['к', 'о', 'т']: три единицы
для одного слова. Мелкие единицы уменьшают потребность в записях для целых форм,
но словарь скалярных значений всё ещё теряет незнакомые значения и не выделяет
графемные кластеры. Размер словаря, охват текста и длина последовательности —
разные стороны компромисса; одно разбиение на скалярные значения не решает все
три задачи.
Сеннрих, Хэддоу и Бёрч (2016) адаптировали кодирование пар байтов, или BPE, для выделения подсловных единиц в нейронном машинном переводе. В их методе исходные последовательности состоят из символов. При обучении формируются единицы, из которых можно составлять слова. Эта работа объясняет, как искать баланс между размером словаря и длиной последовательности; позже в курсе строится другой токенизатор — на основе байтов.
Разберём существующий пример на Rust
Небольшой пример явно показывает разобранные операции. as_bytes() возвращает
байты строки в UTF-8, а chars() перебирает декодированные скалярные значения
Unicode по порядку:
rust/demos/ch01-text-units/src/lib.rs#text-representations /// Copies the UTF-8 code units that make up `text`.
pub fn utf8_bytes(text: &str) -> Vec<u8> {
text.as_bytes().to_vec()
}
/// Decodes `text` into Unicode scalar values in source order.
pub fn unicode_scalars(text: &str) -> Vec<char> {
text.chars().collect()
} Для одного и того же текста text.len()
считает байты UTF-8, а text.chars().count()
считает скалярные значения Unicode. Ни одна из этих операций не считает графемные
кластеры.
Vocabulary::from_training_text сортирует скалярные значения и удаляет повторы.
Сохранённый порядок определяет ID известных значений и позволяет при
декодировании найти их снова. encode выполняет один поиск для каждого
скалярного значения; decode восстанавливает известные значения, записывает
<UNK> для 0 и возвращает ошибку для ID вне допустимого диапазона.
rust/demos/ch01-text-units/src/lib.rs#vocabulary /// A fixed mapping from Unicode scalar values to deterministic integer IDs.
///
/// ID `0` is always [`UNKNOWN_TOKEN_ID`]. Known scalar values are sorted by
/// their numeric Unicode value and receive consecutive IDs beginning at `1`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Vocabulary {
known_units: Vec<char>,
}
impl Vocabulary {
/// Builds a vocabulary from the unique scalar values in `training_text`.
pub fn from_training_text(training_text: &str) -> Self {
let mut known_units = unicode_scalars(training_text);
known_units.sort_unstable();
known_units.dedup();
Self { known_units }
}
/// Returns known units in their deterministic ID order.
pub fn known_units(&self) -> &[char] {
&self.known_units
}
/// Iterates over `(ID, scalar)` pairs, excluding the reserved unknown ID.
pub fn entries(&self) -> impl Iterator<Item = (usize, char)> + '_ {
self.known_units
.iter()
.copied()
.enumerate()
.map(|(index, unit)| (index + 1, unit))
}
/// Looks up one scalar value, returning [`UNKNOWN_TOKEN_ID`] when absent.
pub fn id_for(&self, unit: char) -> usize {
self.known_units
.binary_search(&unit)
.map_or(UNKNOWN_TOKEN_ID, |index| index + 1)
}
/// Looks up one ID.
///
/// `Ok(None)` represents the reserved unknown token. An ID above the
/// vocabulary's largest known ID is an error rather than an unknown token.
pub fn unit_for_id(&self, id: usize) -> Result<Option<char>, InvalidTokenId> {
if id == UNKNOWN_TOKEN_ID {
return Ok(None);
}
self.known_units
.get(id - 1)
.copied()
.map(Some)
.ok_or(InvalidTokenId {
id,
max_id: self.known_units.len(),
})
}
/// Encodes one token ID per Unicode scalar value in `text`.
pub fn encode(&self, text: &str) -> Vec<usize> {
text.chars().map(|unit| self.id_for(unit)).collect()
}
/// Decodes IDs, rendering ID `0` as the literal [`UNKNOWN_TOKEN`].
pub fn decode(&self, ids: &[usize]) -> Result<String, InvalidTokenId> {
let mut text = String::with_capacity(ids.len());
for &id in ids {
match self.unit_for_id(id)? {
Some(unit) => text.push(unit),
None => text.push_str(UNKNOWN_TOKEN),
}
}
Ok(text)
}
} Этот Vocabulary относится к демонстрации ch01-text-units. Основная библиотека
llm-from-scratch не импортирует и не расширяет его. Здесь он нужен, чтобы
показать сопоставление единиц с ID и потерю неизвестных скалярных значений.
Исполняемая программа использует заданный обучающий текст и входные строки разобранного примера:
rust/demos/ch01-text-units/src/main.rs#chapter-output fn main() -> Result<(), InvalidTokenId> {
let vocabulary = Vocabulary::from_training_text(TRAINING_TEXT);
print!("vocabulary: {UNKNOWN_TOKEN}={UNKNOWN_TOKEN_ID}");
for (id, unit) in vocabulary.entries() {
print!(" {unit:?}={id}");
}
println!();
print_example(&vocabulary, ENGLISH_INPUT)?;
print_example(&vocabulary, CYRILLIC_INPUT)?;
println!(
"historical words: {:?} | {:?}",
split_words("red fox"),
split_words("рыжий кот")
);
println!(
"historical scalars: {:?} | {:?}",
split_scalars(ENGLISH_INPUT),
split_scalars(CYRILLIC_INPUT)
);
let unknown_ids = vocabulary.encode(UNKNOWN_INPUT);
println!("unknown input: {UNKNOWN_INPUT}");
println!("unknown token ids: {unknown_ids:?}");
println!("unknown decoded: {}", vocabulary.decode(&unknown_ids)?);
Ok(())
} При желании воспроизведите результаты из корня репозитория. Для этого нужны Cargo и Rust версии 1.93 или новее, как требует репозиторий:
cargo test --locked -p ch01-text-units
cargo run --quiet -p ch01-text-units | diff -u rust/demos/ch01-text-units/expected.txt -
Тесты выбранного пакета сообщают о 6 пройденных тестах библиотеки. Они проверяют точные представления, отсортированные ID, восстановление известных значений, пустой ввод, неизвестный токен и недопустимые ID, а также две функции разбиения. Вторая команда сравнивает вывод заданной программы с сохранённым результатом; при совпадении она ничего не печатает и завершается с кодом ноль.
Проследим путь от байтов к ID
Схема показывает уже разобранные строки cat и кот. Каждое скалярное
значение выровнено со своей группой байтов UTF-8 и своим ID в словаре. Читайте
пронумерованные этапы по порядку: скобки и подписи с числом байтов обозначают
группы независимо от цвета.
cat и кот: байты, скалярные значения и ID токенов
В строках cat и кот каждому скалярному значению соответствует своя группа байтов UTF-8 и ID из фиксированного словаря. В cat на каждое скалярное значение приходится один байт, в кот — два. В обоих примерах получается по три ID.
Пример ASCII: cat
-
Входные единицы
cat
-
Байты UTF-8
-
99Число байтов: 1 -
97Число байтов: 1 -
116Число байтов: 1
-
-
Скалярные значения Unicode
-
U+0063c -
U+0061a -
U+0074t
-
-
ID токенов
324
Пример на кириллице: кот
-
Входные единицы
кот
-
Байты UTF-8
-
208 186Число байтов: 2 -
208 190Число байтов: 2 -
209 130Число байтов: 2
-
-
Скалярные значения Unicode
-
U+043Aк -
U+043Eо -
U+0442т
-
-
ID токенов
567
В обоих примерах по три скалярных значения; каждому соответствует группа байтов и один ID. Число байтов различается, потому что каждому кириллическому скалярному значению здесь нужны два байта. Такое соответствие следует из выбора одного скалярного значения на токен в этом примере; оно не является правилом для любого токенизатора.
Необязательные задания: разобрать и объяснить
Результаты уже приведены выше. Задания возвращаются к ним на тех же заданных строках. Запускать программу необязательно; для запуска подходят команды из раздела с примером на Rust.
- Рассмотрите таблицу представлений и схему. Объясните число байтов, скалярных значений и ID для каждой входной строки.
- Прочитайте ID для
catикотпо фиксированной таблице в порядке входного текста. Объясните, чем первая позиция вcatотличается от ID, который в ней находится. - Объясните, какие сведения теряются, когда корректная строка UTF-8
?превращается в[0]. - Сравните
text.len(),text.chars().count()и число графемных кластеров, опираясь на определения выше и примерéиз двух скалярных значений. - Рассмотрите разобранные примеры разбиения по пробельным символам и по скалярным значениям. Опишите границы каждой функции и объясните, почему разбиение по пробельным символам лишь приближённо выделяет слова.
Ответы к необязательным заданиям
- В
cat— 3 байта, 3 скалярных значения и 3 ID. Вкот— 6 байтов, 3 скалярных значения и 3 ID: каждое скалярное значение занимает два байта UTF-8, но получает один ID. - Последовательности —
[3, 2, 4]и[5, 6, 7]. В нулевой позицииcatнаходится скалярное значениеc; ID3в словаре обозначает это значение. - Байт
[63]и скалярное значениеU+003Fописывают?до поиска в таблице. ID0хранит лишь признак неизвестного значения, поэтому декодирование возвращает<UNK>и не позволяет восстановить вопросительный знак. len()считает байты UTF-8, аchars().count()— скалярные значения. Графемный кластер может содержать несколько скалярных значений, какU+0065иU+0301вé. Совпадение числа байтов, скалярных значений и графемных кластеров не гарантируется.- Программа выводит слова
["red", "fox"] | ["рыжий", "кот"]и скалярные значения['c', 'a', 't'] | ['к', 'о', 'т']. Разбиение по пробельным символам оставляет пунктуацию при словах; разбиение по скалярным значениям следует границам скалярных значений Unicode.
Как это сопоставление используется в модели
Позже каждый ID токена выбирает строку таблицы эмбеддингов — сохранённый вектор, значения которого модель подбирает при обучении. По этим векторам модель вычисляет оценку для каждого возможного следующего ID, а политика декодирования выбирает следующий ID по полученным оценкам. Декодировать выбранный ID нужно с тем же словарём. Если изменить назначения ID в токенизаторе независимо от весов модели, смысл строк таблицы эмбеддингов изменится, даже если сами целые числа остаются допустимыми ID.
В следующих главах учебный словарь скалярных значений заменяется поэтапно. Глава 2 сохраняет границы документов и фиксирует разбиение корпуса на выборки. Глава 3 строит отдельный словарь BPE с базовым токеном для каждого из 256 возможных значений байта и добавляет обученные токены слияний, которые могут представлять несколько байтов. Глава 4 резервирует BOS и EOS — управляющие токены начала и конца документа, переносит ID содержимого в новое пространство ID с отдельной нумерацией и применяет зафиксированный токенизатор.
У каждого байта UTF-8 есть базовый токен, поэтому незнакомые скалярные значения
Unicode можно представить через их байты. Новый токенизатор не наследует правило
учебного примера, по которому незнакомое скалярное значение превращается в
<UNK>. Таблица скалярных значений, назначенные в ней ID и конкретный тип
Vec<usize> остаются в главе 1. Дальше переносится сама идея: фиксированный
словарь придаёт смысл упорядоченной последовательности ID.