← Все главы

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
2aU+0061
3cU+0063
4tU+0074
5кU+043A
6оU+043E
7тU+0442

Результат определяется однозначно и не зависит от порядка обхода хеш-таблицы. Для известных единиц кодирование с последующим декодированием восстанавливает исходный текст. Для отсутствующего в словаре знака ? байт 63 и скалярное значение U+003F доступны до обращения к словарю; после кодирования получаем [0], а после декодирования — <UNK> вместо исходного вопросительного знака.

Vocabulary здесь — небольшая учебная реализация для сравнения, а не токенизатор из следующих глав. Каждому известному скалярному значению Unicode она назначает отдельный ID. Фиксированная таблица позволяет проследить это сопоставление, а единый ID <UNK> наглядно показывает следствие неполного охвата: исходное неизвестное значение восстановить нельзя.

Главная мысль: число байтов UTF-8 и длина последовательности токенов измеряют разные единицы. В этом примере одному скалярному значению Unicode соответствует один ID, поэтому длина последовательности токенов равна числу скалярных значений. При кодировании с последующим декодированием сохраняются только те скалярные значения, которые есть в фиксированном словаре.

Один поиск в словаре для каждого скалярного значения

При кодировании к каждому скалярному значению применяется одно и то же отображение:

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

Иными словами, возьмите скалярное значение в позиции ii, найдите его в фиксированном словаре и поместите полученный целочисленный ID в ту же позицию. Скалярное значение может кодироваться несколькими байтами UTF-8, но в последовательности токенов этой главы ему соответствует ровно одна позиция. Обратная таблица восстанавливает каждое известное скалярное значение; при декодировании ID 00 преобразуется только в маркер <UNK>.

Обозначения

ОбозначениеЗначение
uiu_iскалярное значение Unicode в позиции ii входной последовательности
SSфиксированное множество известных скалярных значений Unicode
VVоднозначно заданное сопоставление скалярного значения с ID токена
ziz_iID токена в позиции ii последовательности
iiпозиция в последовательностях скалярных значений и токенов; отсчёт начинается с нуля
00зарезервированный 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

  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. Меняется только число байтов внутри каждой группы. Для отсутствующего в словаре знака ? также были бы показаны байты и скалярное значение, но на последнем этапе стоял бы зарезервированный ID 0.

Сначала предскажите, затем проверьте

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