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: английская и русская версии шести сцен. Обе версии одной сцены входят в общую группу происхождения и не могут оказаться в разных выборках. Четыре пары дают восемь обучающих документов, одна пара — два валидационных, последняя — два тестовых. Это небольшой проверяемый пример, а не универсальная рекомендация по долям разбиения.
Полнота и непересечение в одной записи
Инвариант разбиения записывается так:
Знак дизъюнктного объединения одновременно утверждает две вещи: три выборки покрывают весь корпус и попарно не пересекаются. Условие с пересечением ещё раз явно фиксирует второе требование для любых двух разных выборок. Только по обучающей выборке можно определять слияния BPE и параметры модели. Валидационную выборку позже используют при выборе настроек. В показанном запуске тестовая выборка не участвует ни в обучении, ни в выборе: в главе 34 один локальный экземпляр оценщика получит к ней доступ только после завершения выбора. Такой порядок внутри запуска ничего не утверждает о том, сколько раз сохранённый в репозитории пример читали при разработке.
Одной формулы недостаточно для реализации. Она ничего не говорит о непустых выборках, изменениях исходных байтов, неизвестных ID, исходном порядке, группах происхождения и репрезентативности данных. Реализация проверяет эти свойства отдельно.
Обозначения
| Обозначение | Значение |
|---|---|
| корпус как множество целых исходных документов | |
| обучающие документы — единственный источник статистики, вычисляемой при обучении токенизатора и модели | |
| валидационные документы, зарезервированные для последующего подбора настроек и выбора контрольной точки | |
| тестовые документы, зарезервированные для итогового отчёта | |
| обозначения обучающей, валидационной и тестовой выборок | |
| дизъюнктное объединение: каждый документ включён и ни один не повторяется | |
| любые два обозначения выборок из , и | |
| пересечение множеств — документы, общие для двух выборок | |
| пустое множество, то есть отсутствие общих документов | |
| условие о пересечении относится только к разным выборкам |
В формуле намеренно нет процентов. Подходящая доля зависит от объёма данных и
цели оценки. Важно, чтобы каждый документ входил ровно в одну выборку.
Распределение 8 / 2 / 2 (восемь документов в обучающей выборке, два — в
валидационной и два — в тестовой) используется только в этом учебном примере.
До появления надёжного разделения обучающих и отложенных данных
Исторически модель было удобно оценивать на том же тексте, на котором её обучали, однако такая оценка показывает лишь, насколько хорошо система описывает уже виденные данные. Она не позволяет независимо судить о качестве на новом тексте. Перекрёстная проверка и фиксированные отложенные выборки позволили отделить обучение от оценки качества предсказаний; в языковом моделировании дополнительно важно правильно выбрать единицу разбиения исходного текста.
Распространённый, но небезопасный порядок действий выглядит так: сначала создают
много частично перекрывающихся фрагментов, присваивают им новые ID, а затем
случайным образом распределяют эти ID по выборкам. Исполняемый пример берёт строку
north star glows softly и строит два окна по три слова. Позиции и имена окон
различаются, но star glows встречается в обоих:
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():
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
- Целый документ
- Идентификатор документа
en-river-dawn- Язык
en- Группа происхождения
pair-river-dawn
- Целый документ
- Идентификатор документа
ru-river-dawn- Язык
ru- Группа происхождения
pair-river-dawn
- Целый документ
- Идентификатор документа
en-clock-shop- Язык
en- Группа происхождения
pair-clock-shop
- Целый документ
- Идентификатор документа
ru-clock-shop- Язык
ru- Группа происхождения
pair-clock-shop
- Целый документ
- Идентификатор документа
en-rain-library- Язык
en- Группа происхождения
pair-rain-library
- Целый документ
- Идентификатор документа
ru-rain-library- Язык
ru- Группа происхождения
pair-rain-library
- Целый документ
- Идентификатор документа
en-bee-garden- Язык
en- Группа происхождения
pair-bee-garden
- Целый документ
- Идентификатор документа
ru-bee-garden- Язык
ru- Группа происхождения
pair-bee-garden
- Целый документ
-
VAВалидационная выборка
Для выбора настроек
Документов 2
- Целый документ
- Идентификатор документа
en-night-station- Язык
en- Группа происхождения
pair-night-station
- Целый документ
- Идентификатор документа
ru-night-station- Язык
ru- Группа происхождения
pair-night-station
- Целый документ
-
TEТестовая выборка
Для оценки после завершения выбора
Документов 2
- Целый документ
- Идентификатор документа
en-winter-window- Язык
en- Группа происхождения
pair-winter-window
- Целый документ
- Идентификатор документа
ru-winter-window- Язык
ru- Группа происхождения
pair-winter-window
- Целый документ
Распределено документов
Повторяющихся ID 0
- Полнота: присутствует каждый ID корпуса
- Непересечение: ID не повторяются
- Документы из одной группы происхождения остаются в одной выборке
Схема показывает то же принятое распределение, которое исполняемая программа
получает из корпуса и файла splits.json. Каждый ID корпуса встречается ровно
один раз; внутри каждой выборки ID следуют в исходном порядке корпуса, а документы
одной группы происхождения остаются в одной выборке. На узком экране области
располагаются друг под другом, но распределение документов и исходный порядок не
меняются.
Сначала предскажите, затем проверьте
- Для некорректного разбиения из шести ID выпишите объединение и три попарных пересечения, а затем исправьте его.
- Определите, по какой выборке можно подсчитывать пары BPE, по какой — выбирать контрольную точку, а какую следует использовать для итогового расчёта функции потерь.
- Объясните, почему два фрагмента с разными именами всё равно могут привести к утечке общего контекста.
- Предскажите первую ошибку после добавления
doc-07в корпус без изменения манифеста. Какая ошибка останется, если обновить в нём только записанную контрольную сумму? - Объясните, почему обратный порядок ID сохраняет множества, но нарушает требование сохранять воспроизводимый порядок.
- Сформулируйте, о чём сообщает совпадение контрольной суммы FNV-1a и почему оно не доказывает полного совпадения байтов.
- Назовите риск, который не устраняет даже корректное воспроизводимое разбиение по документам.
Проверьте ответы
- В объединении нет
doc-06, а пересечение валидационной и тестовой выборок содержитdoc-04. Два остальных пересечения пусты. В исправленном вариантеdoc-05иdoc-06по одному разу входят в тестовую выборку. - Пары BPE подсчитываются только по обучающей выборке, валидационную можно использовать для выбора контрольной точки, а тестовая предоставляет данные для оценки после выбора. В главе 34 этот порядок показан на одном локальном экземпляре оценщика.
- Новый ID не устраняет общие исходные слова и окружающий контекст.
- Сначала будет обнаружено несовпадение контрольной суммы, поскольку байты
корпуса изменились. Если обновить только записанную контрольную сумму,
проверка полноты затем сообщит, что в манифесте нет
doc-07. - Для множеств порядок неважен, но от него зависят дальнейший воспроизводимый обход и отчёт о проверке; поэтому валидатор сохраняет исходный порядок корпуса.
- Совпадение означает, что повторное вычисление дало записанную 64-битную контрольную сумму. Из-за возможной коллизии оно не доказывает полного совпадения байтов, авторства, соблюдения условий лицензии или криптографической подлинности.
- Например, остаются смещение выборки, невыявленные почти дублирующиеся фрагменты и будущий сдвиг распределения.
Меняйте копию манифеста лишь после того, как эталонный пример прошёл проверку. Для каждого изменения сначала предскажите, какая проверка обнаружит нарушение, и только затем запускайте тест.
Передайте алгоритму BPE только обучающие документы
Глава 3 получает через partitions.training_documents() восемь заимствованных
ссылок на целые документы; валидационные и тестовые данные этот интерфейс не
предоставляет. Счётчик соседних пар байтов будет сбрасываться на каждой границе
документа, поэтому ни пара, ни последующее обучающее окно не смогут пересечь
границу между двумя источниками. Отложенные данные не влияют ни на словарь, ни на
ранги слияний.
Для каждого варианта токенизатора или модели правила нормализации, статистика слияний, словарь и веса определяются только по обучающим документам. Валидационная выборка позволяет сравнить варианты, обученные без её данных, и выбрать контрольную точку; её данные не участвуют ни в подсчётах, ни в вычислении градиентов. Тестовая выборка не участвует ни в обучении, ни в выборе. В показанном запуске локальный оценщик получает её только после выбора по валидации; повторное использование известного примера в последующих запусках служит регрессионной проверкой, а не даёт новую независимую оценку. Такое разделение ролей сохраняется во всех последующих главах — от BPE до итогового сравнения декодера.