01 · Версия материала 6
Единицы текста и идентификаторы токенов
Сопоставьте байты UTF-8, скалярные значения Unicode и ID учебного словаря; в следующих главах вместо этого словаря будет построен BPE-токенизатор на уровне байтов.
Начните с двух строк по три буквы
Rust хранит и cat, и кот в UTF-8. Обе строки состоят из трёх видимых букв, но
это не означает, что они занимают одинаковое число байтов. Прежде чем смотреть в
таблицу, предскажите для каждой строки три числа: количество байтов, скалярных
значений Unicode и ID токенов.
| Представление | 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 получается 3 / 3 / 3, а для кот — 6 / 3 / 3. Каждая кириллическая
буква здесь кодируется двумя байтами UTF-8, но обе строки содержат по три
скалярных значения и дают по три ID.
Словарь строится по заранее заданной строке cat кот, которая служит обучающим
примером. В этой главе ID токена — это индекс соответствующей единицы в
словаре. ID 0
зарезервирован для <UNK>; известные скалярные значения сортируются по их
числовым значениям в Unicode и получают ID начиная с 1:
| Идентификатор | Единица | Значение 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 |
Результат определяется однозначно и не зависит от порядка обхода хеш-таблицы.
Для известных единиц кодирование с последующим декодированием восстанавливает
исходный текст. Для отсутствующего в словаре знака ? байт 63 и скалярное
значение U+003F доступны до обращения к словарю; после кодирования получаем
[0], а после декодирования — <UNK> вместо исходного вопросительного знака.
Vocabulary здесь — небольшая учебная реализация для сравнения, а не
токенизатор из следующих глав. Каждому известному скалярному значению Unicode
она назначает отдельный ID. Фиксированная таблица позволяет проследить это
сопоставление, а единый ID <UNK> наглядно показывает следствие неполного
охвата: исходное неизвестное значение восстановить нельзя.
Главная мысль: число байтов UTF-8 и длина последовательности токенов измеряют разные единицы. В этом примере одному скалярному значению Unicode соответствует один ID, поэтому длина последовательности токенов равна числу скалярных значений. При кодировании с последующим декодированием сохраняются только те скалярные значения, которые есть в фиксированном словаре.
Один поиск в словаре для каждого скалярного значения
При кодировании к каждому скалярному значению применяется одно и то же отображение:
Иными словами, возьмите скалярное значение в позиции , найдите его в
фиксированном словаре и поместите полученный целочисленный ID в ту же позицию.
Скалярное значение может кодироваться несколькими байтами UTF-8, но в
последовательности токенов этой главы ему соответствует ровно одна позиция.
Обратная таблица восстанавливает каждое известное скалярное значение; при
декодировании ID преобразуется только в маркер <UNK>.
Обозначения
| Обозначение | Значение |
|---|---|
| скалярное значение Unicode в позиции входной последовательности | |
| фиксированное множество известных скалярных значений Unicode | |
| однозначно заданное сопоставление скалярного значения с ID токена | |
| ID токена в позиции последовательности | |
| позиция в последовательностях скалярных значений и токенов; отсчёт начинается с нуля | |
зарезервированный ID токена <UNK> |
До субсловных токенизаторов
Один из ранних и наглядных подходов — разбивать текст по пробельным символам и
строить словарь целых слов: red fox превращается в ["red", "fox"]. Такой
подход легко объяснить, но для каждого ранее не встречавшегося написания, новой
словоформы или слова с присоединённым знаком препинания нужна отдельная запись;
иначе вся форма кодируется как неизвестная.
В посимвольных моделях используются гораздо меньшие единицы. В этой главе под
«посимвольным уровнем» мы понимаем разбиение на скалярные значения Unicode,
которые возвращает str::chars в Rust, а не на графемные кластеры,
воспринимаемые пользователем как отдельные символы. Такое разбиение не сводит
целое незнакомое слово к одному <UNK>, но даёт более длинные последовательности;
одна видимая графема по-прежнему может состоять из нескольких скалярных значений.
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_words("red fox") возвращает ["red", "fox"], а
split_scalars("кот") — ['к', 'о', 'т']. Современные субсловные методы
стремятся найти практичный компромисс между огромными словарями целых слов и
длинными последовательностями скалярных значений. Во второй главе мы сохраним
границы документов и зафиксируем
разбиение корпуса на выборки; в третьей построим упорядоченный список правил
слияния BPE, а в четвёртой применим эти правила к новым входным данным.
Реализуйте отображение на Rust
Демонстрация использует только стандартную библиотеку Rust. Сначала явно покажем различие между байтовым и скалярным представлениями, не скрывая его за словом «символ»:
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()
} Затем Vocabulary::from_training_text сортирует скалярные значения и удаляет
повторы. Отсортированный вектор одновременно задаёт порядок ID и служит обратной
таблицей, поэтому известные скалярные значения можно кодировать в 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
токенов и представляемыми ими единицами; при одинаковом входе получать одинаковые
ID; точно восстанавливать при декодировании единицы, представленные в словаре;
осознанно выбирать компромисс между охватом текста и длиной последовательности.
Записи для скалярных значений, их числовые ID и правило <UNK> для незнакомого
скалярного значения относятся только к этому примеру и не входят в состояние
BPE-токенизатора.
Исполняемая программа использует заранее заданные входные строки и печатает каждое промежуточное представление:
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 test --workspace --locked
cargo run --quiet -p ch01-text-units | diff -u rust/demos/ch01-text-units/expected.txt -
Тесты проверяют воспроизводимый порядок, точные значения для примеров ASCII и
кириллицы, восстановление известных строк после кодирования и декодирования,
пустую строку, неизвестное скалярное значение и недопустимый ID, а также обе
исторические функции разбиения. При успехе diff ничего не печатает.
Проследите каждую позицию во всех представлениях
Диаграмма объединяет байты, кодирующие одно скалярное значение, и показывает соответствующий итоговый ID. Читайте пронумерованные этапы по порядку. Группировка понятна без опоры на цвет благодаря скобкам и подписям с числом байтов.
Одни и те же три позиции в четырёх представлениях
Проследите каждый пример: от входных единиц через байты UTF-8 и скалярные значения Unicode к 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. Меняется только число байтов внутри каждой группы.
Для отсутствующего в словаре знака ? также были бы показаны байты и скалярное
значение, но на последнем этапе стоял бы зарезервированный ID 0.
Сначала предскажите, затем проверьте
- Не запуская программу, запишите количество байтов, скалярных значений и ID для
catикот. - Отсортируйте скалярные значения по числовым значениям Unicode и предскажите ID обеих строк.
- Определите, какая информация остаётся наблюдаемой и какая теряется, когда
?превращается в ID0. - Объясните, почему
text.len(),text.chars().count()и число воспринимаемых пользователем графем могут не совпадать. - Предскажите, какие строки со словами и скалярными значениями напечатает исполняемая программа в историческом сравнении.
Проверьте ответы
- Для
catчисло байтов / скалярных значений / ID равно3 / 3 / 3, длякот—6 / 3 / 3. - Последовательности ID токенов:
[3, 2, 4]и[5, 6, 7]. - Байт
63и скалярное значениеU+003Fвидны до обращения к словарю. После него[0]сохраняет лишь признак неизвестной единицы, поэтому декодирование даёт<UNK>, а не?. len()считает байты UTF-8,chars().count()— скалярные значения Unicode, а одна видимая графема может состоять из нескольких скалярных значений.- Программа печатает слова
["red", "fox"] | ["рыжий", "кот"]и скалярные значения['c', 'a', 't'] | ['к', 'о', 'т'].
Измените одну из входных строк только после того, как заданный пример
пройдёт проверку. Добавив скалярное значение, которого нет в cat кот,
предскажите 0, а затем подтвердите результат с помощью encode.
Интерфейс между токенизатором и декодером
В этой главе показан общий интерфейс: текст однозначно преобразуется в последовательность ID токенов. Конкретный тип ID в Rust и таблица токенов из учебного примера не переходят в основную реализацию модели. В ней каждый ID выбирает строку таблицы эмбеддингов перед поступлением последовательности в декодер. Затем модель предсказывает новый ID, а обратное сопоставление того же словаря преобразует его в текст.
В главе 2 мы сохраним границы документов и зафиксируем разбиение корпуса на
выборки. В главе 3 будет создан отдельный словарь для обучения BPE: каждому из
256 возможных значений байта будет соответствовать отдельный токен, а обученные
токены слияний смогут представлять последовательности из нескольких байтов. В
главе 4 будут зарезервированы BOS и EOS, а ID содержимого будут перенесены в
собственное пространство ID токенизатора; затем зафиксированные правила BPE
будут применяться к входным байтам. Каждому байту UTF-8 соответствует базовый
токен, поэтому новый токенизатор не наследует правило главы 1, по которому
незнакомое скалярное значение превращается в <UNK>.
Таким образом, учебный Vocabulary будет заменён, а не расширен. В следующих
главах сохранятся требование зафиксировать соответствие и интерфейс
последовательности целочисленных ID. Благодаря этому эмбеддинги, внимание и
предсказание следующего токена смогут работать с ID, не зная, какие единицы
текста выбраны в конкретном словаре.