← Все главы

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
2aU+0061
3cU+0063
4tU+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; другой токенизатор может выбрать другие единицы.

Один поиск для каждой позиции

В разобранных примерах в каждой позиции действует одно и то же правило:

zi=V(ui),ui∉S⇒V(ui)=0z_i = V(u_i), \quad u_i \notin S \Rightarrow V(u_i)=0

Для входного скалярного значения uiu_i в позиции ii поиск VV возвращает ID ziz_i, который занимает ту же позицию в выходной последовательности. Множество SS содержит ровно семь известных скалярных значений из cat кот, включая пробел. Если значения нет в SS, поиск возвращает зарезервированный ID 00.

Отсчёт позиций начинается с нуля. В cat индекс i=0i=0 обозначает первую позицию: в ней находится скалярное значение c, а его ID — 3. Позиция указывает, где находится значение; ID указывает, какую запись словаря оно представляет. Поскольку на каждое скалярное значение приходится один поиск, число скалярных значений и число ID совпадают независимо от количества байтов UTF-8.

Каждому известному скалярному значению назначен отдельный ID. Поэтому декодирование известных ID по порядку восстанавливает исходную последовательность скалярных значений. Именно так cat и кот сохраняются после кодирования и декодирования. То же рассуждение применимо к любой последовательности, целиком составленной из известных скалярных значений этой таблицы.

Входная строка ? показывает границу этой гарантии. Её байты UTF-8 — [63], а скалярное значение — U+003F, которого нет в таблице. Поэтому кодирование даёт [0], а декодирование — строку <UNK>. Все неизвестные скалярные значения получают один ID, и при декодировании нельзя определить, какое именно значение было заменено. Причина потери — неполный охват словаря, хотя ? является корректным текстом UTF-8. Передача ID вне таблицы, например 8, — другая ситуация: существующая функция decode возвращает ошибку.

Обозначения в правиле поиска

ОбозначениеСмысл в этом примере
uiu_iвходное скалярное значение Unicode в позиции ii
SSсемь известных скалярных значений из cat кот, включая пробел
VVпоиск по фиксированной таблице: ID известного значения либо 00, если записи нет
ziz_iID токена в той же позиции ii, что и входное скалярное значение
iiпозиция в последовательности с отсчётом от нуля, отличная от ID
00зарезервированный ID токена, который декодируется в <UNK>

Зачем нужны единицы мельче слова

Словарю целых слов нужны отдельные записи для разных форм. Если новой формы нет в фиксированной таблице, она считается неизвестной. Из меньших известных единиц можно составить форму, которую словарь никогда не хранил целиком, но тогда слово из нескольких букв занимает больше позиций в последовательности.

В существующем сравнении на Rust сначала используются пробельные границы: red fox превращается в ["red", "fox"], а рыжий кот — в ["рыжий", "кот"]. Метод Rust split_whitespace разбивает текст по пробельным символам, оставляя пунктуацию при словах. Это простой пример единиц размером со слово, а не полноценное лингвистическое разбиение на слова.

Разбиение по пробельным символам и по скалярным значениям Unicode 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 по порядку:

Байты строки в UTF-8 и декодированные скалярные значения 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

  1. Входные единицы

    1. c
    2. a
    3. t
  2. Байты UTF-8

    1. 99 Число байтов: 1
    2. 97 Число байтов: 1
    3. 116 Число байтов: 1
  3. Скалярные значения Unicode

    1. U+0063 c
    2. U+0061 a
    3. U+0074 t
  4. ID токенов

    1. 3
    2. 2
    3. 4

Пример на кириллице: кот

  1. Входные единицы

    1. к
    2. о
    3. т
  2. Байты UTF-8

    1. 208 186 Число байтов: 2
    2. 208 190 Число байтов: 2
    3. 209 130 Число байтов: 2
  3. Скалярные значения Unicode

    1. U+043A к
    2. U+043E о
    3. U+0442 т
  4. ID токенов

    1. 5
    2. 6
    3. 7

В обоих примерах по три скалярных значения; каждому соответствует группа байтов и один ID. Число байтов различается, потому что каждому кириллическому скалярному значению здесь нужны два байта. Такое соответствие следует из выбора одного скалярного значения на токен в этом примере; оно не является правилом для любого токенизатора.

Необязательные задания: разобрать и объяснить

Результаты уже приведены выше. Задания возвращаются к ним на тех же заданных строках. Запускать программу необязательно; для запуска подходят команды из раздела с примером на Rust.

  1. Рассмотрите таблицу представлений и схему. Объясните число байтов, скалярных значений и ID для каждой входной строки.
  2. Прочитайте ID для cat и кот по фиксированной таблице в порядке входного текста. Объясните, чем первая позиция в cat отличается от ID, который в ней находится.
  3. Объясните, какие сведения теряются, когда корректная строка UTF-8 ? превращается в [0].
  4. Сравните text.len(), text.chars().count() и число графемных кластеров, опираясь на определения выше и пример é из двух скалярных значений.
  5. Рассмотрите разобранные примеры разбиения по пробельным символам и по скалярным значениям. Опишите границы каждой функции и объясните, почему разбиение по пробельным символам лишь приближённо выделяет слова.
Ответы к необязательным заданиям
  1. В cat — 3 байта, 3 скалярных значения и 3 ID. В кот — 6 байтов, 3 скалярных значения и 3 ID: каждое скалярное значение занимает два байта UTF-8, но получает один ID.
  2. Последовательности — [3, 2, 4] и [5, 6, 7]. В нулевой позиции cat находится скалярное значение c; ID 3 в словаре обозначает это значение.
  3. Байт [63] и скалярное значение U+003F описывают ? до поиска в таблице. ID 0 хранит лишь признак неизвестного значения, поэтому декодирование возвращает <UNK> и не позволяет восстановить вопросительный знак.
  4. len() считает байты UTF-8, а chars().count() — скалярные значения. Графемный кластер может содержать несколько скалярных значений, как U+0065 и U+0301 в é. Совпадение числа байтов, скалярных значений и графемных кластеров не гарантируется.
  5. Программа выводит слова ["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.