← Все главы

02 · Версия материала 9

Документы корпуса и фиксированное разбиение на выборки

Распределите целые исходные документы между тремя непересекающимися выборками — обучающей, валидационной и тестовой — прежде чем эти документы будут использованы для обучения токенизатора или модели.

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

Пусть в корпусе шесть документов со стабильными идентификаторами от doc-01 до doc-06. Прежде чем читать ответ, найдите ошибки в таком правдоподобном разбиении:

train      = [doc-01, doc-02]
validation = [doc-03, doc-04]
test       = [doc-04, doc-05]

Здесь нарушены два разных свойства. doc-04 попал и в валидационную, и в тестовую выборку, поэтому они пересекаются. doc-06 не попал никуда, поэтому объединение выборок не покрывает корпус. Один из вариантов исправления:

train      = [doc-01, doc-02]
validation = [doc-03, doc-04]
test       = [doc-05, doc-06]

Исправленные списки безопасны только в том случае, если каждый указанный в них ID обозначает целый исходный документ. Разные ID сами по себе ещё не означают, что за ними стоит разный текст. Например, строку north star glows softly можно разбить на два окна и назвать их window-A (north star glows) и window-B (star glows softly). Имена у окон разные, но сочетание star glows присутствует в обоих. Если первое окно поместить в обучающую выборку, а второе — в валидационную, обе выборки будут содержать общий исходный текст и контекст.

Поэтому порядок действий должен быть таким: сначала целиком отнести исходный документ ровно к одной выборке, затем токенизировать его, а все созданные из него окна оставить в той же выборке. Нельзя сначала нарезать документ на перекрывающиеся окна, а потом независимо распределить их по выборкам: разные ID окон скроют общий исходный контекст.

В репозитории лежат двенадцать коротких документов в кодировке UTF-8: английская и русская версии шести сцен. Обе версии одной сцены входят в общую группу происхождения и не могут оказаться в разных выборках. Четыре пары дают восемь обучающих документов, одна пара — два валидационных, последняя — два тестовых. Это небольшой проверяемый пример, а не универсальная рекомендация по долям разбиения.

Полнота и непересечение в одной записи

Инвариант разбиения записывается так:

𝒟=𝒟tr˙𝒟va˙𝒟te,𝒟a𝒟b=  (ab)\mathcal{D}=\mathcal{D}_{tr}\mathbin{\dot\cup}\mathcal{D}_{va}\mathbin{\dot\cup}\mathcal{D}_{te},\quad \mathcal{D}_{a}\cap\mathcal{D}_{b}=\varnothing\;(a\ne b)

Знак дизъюнктного объединения одновременно утверждает две вещи: три выборки покрывают весь корпус и попарно не пересекаются. Условие с пересечением ещё раз явно фиксирует второе требование для любых двух разных выборок. Только по обучающей выборке можно определять слияния BPE и параметры модели. Валидационную выборку позже используют при выборе настроек. В показанном запуске тестовая выборка не участвует ни в обучении, ни в выборе: в главе 34 один локальный экземпляр оценщика получит к ней доступ только после завершения выбора. Такой порядок внутри запуска ничего не утверждает о том, сколько раз сохранённый в репозитории пример читали при разработке.

Одной формулы недостаточно для реализации. Она ничего не говорит о непустых выборках, изменениях исходных байтов, неизвестных ID, исходном порядке, группах происхождения и репрезентативности данных. Реализация проверяет эти свойства отдельно.

Обозначения

ОбозначениеЗначение
𝒟\mathcal{D}корпус как множество целых исходных документов
𝒟tr\mathcal{D}_{tr}обучающие документы — единственный источник статистики, вычисляемой при обучении токенизатора и модели
𝒟va\mathcal{D}_{va}валидационные документы, зарезервированные для последующего подбора настроек и выбора контрольной точки
𝒟te\mathcal{D}_{te}тестовые документы, зарезервированные для итогового отчёта
tr,va,tetr,va,teобозначения обучающей, валидационной и тестовой выборок
˙\dot\cupдизъюнктное объединение: каждый документ включён и ни один не повторяется
a,ba,bлюбые два обозначения выборок из trtr, vava и tete
\capпересечение множеств — документы, общие для двух выборок
\varnothingпустое множество, то есть отсутствие общих документов
aba\ne bусловие о пересечении относится только к разным выборкам

В формуле намеренно нет процентов. Подходящая доля зависит от объёма данных и цели оценки. Важно, чтобы каждый документ входил ровно в одну выборку. Распределение 8 / 2 / 2 (восемь документов в обучающей выборке, два — в валидационной и два — в тестовой) используется только в этом учебном примере.

До появления надёжного разделения обучающих и отложенных данных

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

Распространённый, но небезопасный порядок действий выглядит так: сначала создают много частично перекрывающихся фрагментов, присваивают им новые ID, а затем случайным образом распределяют эти ID по выборкам. Исполняемый пример берёт строку north star glows softly и строит два окна по три слова. Позиции и имена окон различаются, но star glows встречается в обоих:

Разные ID фрагментов с общим исходным контекстом rust/demos/ch02-corpus-partitions/src/lib.rs#overlapping-excerpts
pub fn overlapping_word_windows(text: &str, width: usize) -> Vec<Vec<&str>> {
    let words = text.split_whitespace().collect::<Vec<_>>();
    if width == 0 || width > words.len() {
        return Vec::new();
    }
    words.windows(width).map(<[&str]>::to_vec).collect()
}

/// Lists tokens shared by two separately named excerpts in left-hand order.
pub fn shared_words<'a>(left: &'a [&'a str], right: &[&str]) -> Vec<&'a str> {
    left.iter()
        .copied()
        .filter(|word| right.contains(word))
        .collect()
}

Историческое развитие отражено в первичных источниках. В статье 1974 года Стоун формализовал выбор модели и оценку её качества с помощью перекрёстной проверки. Позднее Мерити и соавторы представили рассчитанный на длинный контекст корпус WikiText для языкового моделирования, а Браун и соавторы включили в работу о большой языковой модели отдельный анализ загрязнения обучающих данных.

Распределение целых документов предотвращает именно такой вид утечки. Оно не доказывает репрезентативность корпуса, а проверка по ID не обнаруживает все копии и почти одинаковые фрагменты. Валидационную выборку уже нельзя считать неиспользованной после того, как она повлияла на решение. Общая роль тестовой выборки — предоставлять данные для оценки после завершения выбора. Гарантия, которую обеспечивает программа, имеет более узкую область: в пределах одного запуска тест не может повлиять на выбранное состояние, а проверка сохранённого в репозитории примера может выполняться повторно как регрессионная.

Проверьте границу данных в Rust

Файл корпуса представляет собой обычный массив JSON, каждый элемент которого содержит один целый исходный документ. Corpus::from_json принимает текст JSON как &str. Тип &str уже гарантирует корректность UTF-8. Затем serde_json::from_str разбирает JSON и десериализует четыре обязательных поля каждого элемента во внутреннюю структуру Rust. После этого метод проверяет правила, которым должен соответствовать документ в этом примере: ID, обозначение языка и группа происхождения имеют формат ASCII kebab-case — каждая непустая часть состоит только из строчных латинских букв и цифр, части разделены одиночными дефисами, а первый символ — строчная латинская буква; текст содержит хотя бы один непробельный символ; ID и десериализованные тексты не повторяются; порядок элементов массива сохранён:

Загрузка целых документов до токенизации rust/crates/llm-from-scratch/src/corpus.rs#document-loader
    pub fn from_json(source: &str) -> Result<Self, CorpusError> {
        let decoded: Vec<DocumentJson> = serde_json::from_str(source)
            .map_err(|error| CorpusError::new(format!("invalid corpus JSON: {error}")))?;
        if decoded.is_empty() {
            return Err(CorpusError::new("corpus contains no documents"));
        }

        let mut documents = Vec::with_capacity(decoded.len());
        for (index, document) in decoded.into_iter().enumerate() {
            let position = index + 1;
            for (value, label) in [
                (&document.id, "document ID"),
                (&document.language, "language"),
                (&document.provenance_group, "provenance group"),
            ] {
                if !is_kebab_identifier(value) {
                    return Err(CorpusError::new(format!(
                        "corpus document {position} {label} must be lowercase ASCII kebab case"
                    )));
                }
            }
            if document.text.trim().is_empty() {
                return Err(CorpusError::new(format!(
                    "corpus document {position} text is empty"
                )));
            }
            if documents
                .iter()
                .any(|existing: &Document| existing.id == document.id)
            {
                return Err(CorpusError::new(format!(
                    "duplicate document ID {}",
                    document.id
                )));
            }
            if documents
                .iter()
                .any(|existing: &Document| existing.text == document.text)
            {
                return Err(CorpusError::new(
                    "duplicate document text would leak identical content",
                ));
            }
            documents.push(Document {
                id: document.id,
                language: document.language,
                provenance_group: document.provenance_group,
                text: document.text,
            });
        }

        Ok(Self {
            documents,
            checksum: format!("fnv1a64:{:016x}", fnv1a64(source.as_bytes())),
        })
    }

Загрузчик вычисляет воспроизводимую 64-битную контрольную сумму FNV-1a по байтам UTF-8 переданной строки JSON. include_str! сохраняет точный текст файла из репозитория. Если тест имитирует случайное изменение исходного текста, вычисленная контрольная сумма меняется. Совпадение означает лишь, что повторное вычисление дало записанное 64-битное значение. Однако у FNV возможны коллизии, поэтому совпадение контрольных сумм не доказывает полного совпадения байтов, авторства, соблюдения лицензии или достоверности происхождения.

SplitManifest::from_json использует отдельную внутреннюю структуру данных Rust для шести обязательных полей манифеста и также принимает &str. serde_json::from_str разбирает синтаксис JSON и десериализует типизированные поля обоих входов. Внутренние структуры требуют все объявленные поля и ожидаемые типы значений, отклоняют повторяющиеся поля, а deny_unknown_fields запрещает дополнительные поля. Эти проверки формата не определяют, соблюдены ли инварианты распределения документов между обучающей, валидационной и тестовой выборками.

partition выполняет проверки, относящиеся к самому разбиению. Метод отклоняет неподдерживаемую версию схемы или стратегию, несовпадение контрольной суммы, неизвестные и повторные ID, пропуски, пустые выборки, перестановку ID и разделение одной группы происхождения между выборками. Заимствованные ссылки на документы возвращаются лишь после всех проверок:

Проверка перед возвратом заимствованных ссылок на документы rust/crates/llm-from-scratch/src/corpus.rs#partition-invariants
    pub fn partition<'a>(&self, corpus: &'a Corpus) -> Result<CorpusPartitions<'a>, CorpusError> {
        if self.schema_version != SPLIT_SCHEMA_VERSION {
            return Err(CorpusError::new(format!(
                "unsupported split schema version {}",
                self.schema_version
            )));
        }
        if self.strategy != SPLIT_STRATEGY {
            return Err(CorpusError::new(format!(
                "unsupported split strategy {}",
                self.strategy
            )));
        }
        if self.corpus_checksum != corpus.checksum {
            return Err(CorpusError::new(format!(
                "corpus checksum mismatch: manifest={}, actual={}",
                self.corpus_checksum, corpus.checksum
            )));
        }
        for partition in [Partition::Train, Partition::Validation, Partition::Test] {
            if self.ids(partition).is_empty() {
                return Err(CorpusError::new(format!(
                    "{} partition is empty",
                    partition.label()
                )));
            }
            validate_source_order(corpus, partition, self.ids(partition))?;
        }

        let mut seen = Vec::new();
        for partition in [Partition::Train, Partition::Validation, Partition::Test] {
            for id in self.ids(partition) {
                if corpus.document(id).is_none() {
                    return Err(CorpusError::new(format!(
                        "{} partition contains unknown document {id}",
                        partition.label()
                    )));
                }
                if seen.contains(&id) {
                    return Err(CorpusError::new(format!(
                        "document {id} appears in more than one manifest position"
                    )));
                }
                seen.push(id);
            }
        }
        if seen.len() != corpus.documents.len() {
            let missing = corpus
                .documents
                .iter()
                .find(|document| !seen.iter().any(|id| id.as_str() == document.id))
                .map_or("<unknown>", Document::id);
            return Err(CorpusError::new(format!(
                "manifest does not cover corpus document {missing}"
            )));
        }

        for document in &corpus.documents {
            let assigned = self.assignment(document.id()).ok_or_else(|| {
                CorpusError::new(format!(
                    "manifest does not cover corpus document {}",
                    document.id
                ))
            })?;
            if let Some(related) = corpus.documents.iter().find(|candidate| {
                candidate.provenance_group == document.provenance_group
                    && self.assignment(candidate.id()) != Some(assigned)
            }) {
                return Err(CorpusError::new(format!(
                    "provenance group {} is split between {} and {}",
                    document.provenance_group, document.id, related.id
                )));
            }
        }

        let mut partitions = CorpusPartitions {
            train: Vec::new(),
            validation: Vec::new(),
            test: Vec::new(),
        };
        for document in &corpus.documents {
            match self.assignment(document.id()).ok_or_else(|| {
                CorpusError::new(format!(
                    "manifest does not cover corpus document {}",
                    document.id
                ))
            })? {
                Partition::Train => partitions.train.push(document),
                Partition::Validation => partitions.validation.push(document),
                Partition::Test => partitions.test.push(document),
            }
        }
        Ok(partitions)
    }

Исполняемая программа загружает эталонный корпус вместе с манифестом, выводит распределение всех документов и передаёт следующей главе только training_documents():

Воспроизводимая проверка главы 2 rust/demos/ch02-corpus-partitions/src/main.rs#chapter-output
fn main() -> Result<(), Box<dyn std::error::Error>> {
    let corpus = Corpus::from_json(CORPUS_JSON)?;
    let manifest = SplitManifest::from_json(SPLIT_MANIFEST)?;
    let partitions = manifest.partition(&corpus)?;

    println!("corpus checksum: {}", corpus.checksum());
    println!("documents: {}", corpus.documents().len());
    print_partition("train", &partitions, Partition::Train);
    print_partition("validation", &partitions, Partition::Validation);
    print_partition("test", &partitions, Partition::Test);
    println!("complete: yes");
    println!("disjoint: yes");
    println!("provenance groups intact: yes");

    let excerpts = overlapping_word_windows("north star glows softly", 3);
    println!("historical excerpt A: {:?}", excerpts[0]);
    println!("historical excerpt B: {:?}", excerpts[1]);
    println!(
        "shared context: {:?}",
        shared_words(&excerpts[0], &excerpts[1])
    );
    println!("safe split unit: whole source document");
    println!(
        "chapter 3 tokenizer input: train only ({} documents)",
        partitions.training_documents().len()
    );
    println!(
        "held out: validation={} test={}",
        partitions.documents(Partition::Validation).len(),
        partitions.documents(Partition::Test).len()
    );

    Ok(())
}

Сначала запустите тесты, затем сравните вывод с эталоном:

cargo test --workspace --locked
cargo run --quiet --locked -p ch02-corpus-partitions | diff -u rust/demos/ch02-corpus-partitions/expected.txt -

Тесты этой главы охватывают корректное разбиение на три выборки и независимо проверяют некорректный JSON, несовпадение контрольной суммы, пропущенные, повторные, неизвестные и переставленные ID, пустые выборки и разделённую группу происхождения. При успехе diff ничего не печатает.

Проверьте распределение всех двенадцати документов на одной диаграмме

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

Один корпус, три непересекающиеся выборки

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

  • TR

    Обучающая выборка

    Для обучения

    Документов 8

    1. Целый документ
      Идентификатор документа
      en-river-dawn
      Язык
      en
      Группа происхождения
      pair-river-dawn
    2. Целый документ
      Идентификатор документа
      ru-river-dawn
      Язык
      ru
      Группа происхождения
      pair-river-dawn
    3. Целый документ
      Идентификатор документа
      en-clock-shop
      Язык
      en
      Группа происхождения
      pair-clock-shop
    4. Целый документ
      Идентификатор документа
      ru-clock-shop
      Язык
      ru
      Группа происхождения
      pair-clock-shop
    5. Целый документ
      Идентификатор документа
      en-rain-library
      Язык
      en
      Группа происхождения
      pair-rain-library
    6. Целый документ
      Идентификатор документа
      ru-rain-library
      Язык
      ru
      Группа происхождения
      pair-rain-library
    7. Целый документ
      Идентификатор документа
      en-bee-garden
      Язык
      en
      Группа происхождения
      pair-bee-garden
    8. Целый документ
      Идентификатор документа
      ru-bee-garden
      Язык
      ru
      Группа происхождения
      pair-bee-garden
  • VA

    Валидационная выборка

    Для выбора настроек

    Документов 2

    1. Целый документ
      Идентификатор документа
      en-night-station
      Язык
      en
      Группа происхождения
      pair-night-station
    2. Целый документ
      Идентификатор документа
      ru-night-station
      Язык
      ru
      Группа происхождения
      pair-night-station
  • TE

    Тестовая выборка

    Для оценки после завершения выбора

    Документов 2

    1. Целый документ
      Идентификатор документа
      en-winter-window
      Язык
      en
      Группа происхождения
      pair-winter-window
    2. Целый документ
      Идентификатор документа
      ru-winter-window
      Язык
      ru
      Группа происхождения
      pair-winter-window

Распределено документов 1212\frac{12}{12}

Повторяющихся ID 0

  • Полнота: присутствует каждый ID корпуса
  • Непересечение: ID не повторяются
  • Документы из одной группы происхождения остаются в одной выборке

Схема показывает то же принятое распределение, которое исполняемая программа получает из корпуса и файла splits.json. Каждый ID корпуса встречается ровно один раз; внутри каждой выборки ID следуют в исходном порядке корпуса, а документы одной группы происхождения остаются в одной выборке. На узком экране области располагаются друг под другом, но распределение документов и исходный порядок не меняются.

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

  1. Для некорректного разбиения из шести ID выпишите объединение и три попарных пересечения, а затем исправьте его.
  2. Определите, по какой выборке можно подсчитывать пары BPE, по какой — выбирать контрольную точку, а какую следует использовать для итогового расчёта функции потерь.
  3. Объясните, почему два фрагмента с разными именами всё равно могут привести к утечке общего контекста.
  4. Предскажите первую ошибку после добавления doc-07 в корпус без изменения манифеста. Какая ошибка останется, если обновить в нём только записанную контрольную сумму?
  5. Объясните, почему обратный порядок ID сохраняет множества, но нарушает требование сохранять воспроизводимый порядок.
  6. Сформулируйте, о чём сообщает совпадение контрольной суммы FNV-1a и почему оно не доказывает полного совпадения байтов.
  7. Назовите риск, который не устраняет даже корректное воспроизводимое разбиение по документам.
Проверьте ответы
  1. В объединении нет doc-06, а пересечение валидационной и тестовой выборок содержит doc-04. Два остальных пересечения пусты. В исправленном варианте doc-05 и doc-06 по одному разу входят в тестовую выборку.
  2. Пары BPE подсчитываются только по обучающей выборке, валидационную можно использовать для выбора контрольной точки, а тестовая предоставляет данные для оценки после выбора. В главе 34 этот порядок показан на одном локальном экземпляре оценщика.
  3. Новый ID не устраняет общие исходные слова и окружающий контекст.
  4. Сначала будет обнаружено несовпадение контрольной суммы, поскольку байты корпуса изменились. Если обновить только записанную контрольную сумму, проверка полноты затем сообщит, что в манифесте нет doc-07.
  5. Для множеств порядок неважен, но от него зависят дальнейший воспроизводимый обход и отчёт о проверке; поэтому валидатор сохраняет исходный порядок корпуса.
  6. Совпадение означает, что повторное вычисление дало записанную 64-битную контрольную сумму. Из-за возможной коллизии оно не доказывает полного совпадения байтов, авторства, соблюдения условий лицензии или криптографической подлинности.
  7. Например, остаются смещение выборки, невыявленные почти дублирующиеся фрагменты и будущий сдвиг распределения.

Меняйте копию манифеста лишь после того, как эталонный пример прошёл проверку. Для каждого изменения сначала предскажите, какая проверка обнаружит нарушение, и только затем запускайте тест.

Передайте алгоритму BPE только обучающие документы

Глава 3 получает через partitions.training_documents() восемь заимствованных ссылок на целые документы; валидационные и тестовые данные этот интерфейс не предоставляет. Счётчик соседних пар байтов будет сбрасываться на каждой границе документа, поэтому ни пара, ни последующее обучающее окно не смогут пересечь границу между двумя источниками. Отложенные данные не влияют ни на словарь, ни на ранги слияний.

Для каждого варианта токенизатора или модели правила нормализации, статистика слияний, словарь и веса определяются только по обучающим документам. Валидационная выборка позволяет сравнить варианты, обученные без её данных, и выбрать контрольную точку; её данные не участвуют ни в подсчётах, ни в вычислении градиентов. Тестовая выборка не участвует ни в обучении, ни в выборе. В показанном запуске локальный оценщик получает её только после выбора по валидации; повторное использование известного примера в последующих запусках служит регрессионной проверкой, а не даёт новую независимую оценку. Такое разделение ролей сохраняется во всех последующих главах — от BPE до итогового сравнения декодера.