🔴 Сложный ⏱️ 45 минут

Train/validation/test split

📋 Содержание урока

Train/validation/test split ✂️

Train/validation/test split — то есть разбиение на обучающую, валидационную и тестовую выборки — звучит как техническая деталь, о которой можно вспомнить между делом. На самом деле это одно из тех решений, которые определяют, можно ли вообще доверять цифрам, которые твоя модель показывает. Ты уже знаешь из урока 304, что модель, идеально подогнанная под тренировочные данные, может оказаться совершенно бесполезной на новых — это переобучение. Но у этой истории есть более коварное продолжение, с которым сталкивается почти каждый начинающий специалист по данным, причём далеко не сразу понимает, что произошло.

Представь: ты разделил данные на train и test, обучил модель, проверил качество на test — так себе. Подкрутил гиперпараметр, снова проверил на test — чуть лучше. Подобрал другую архитектуру, ещё раз проверил на test — ещё лучше. Через двадцать таких итераций ты получишь модель с отличной метрикой на test — и это будет обман, в первую очередь самого себя. Тестовая выборка в этом сценарии перестаёт быть независимым судьёй: ты, по сути, обучаешь модель на train, а затем ещё и постепенно подгоняешь её структуру — гиперпараметры, архитектуру, набор признаков — под test, просматривая его снова и снова. Тестовая выборка постепенно «утекает» в процесс обучения — не напрямую через веса модели, а через твои собственные решения о том, какую версию модели оставить.

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

В этом уроке разберём, зачем нужны именно три выборки, а не две, какие пропорции разбиения используют на практике, чем отличается случайное разбиение от стратифицированного, почему для временных рядов нельзя просто перемешать данные — и, самое важное для практики, что такое утечка данных (data leakage) и как её избегать в конкретных, часто встречающихся сценариях. Именно утечка данных — тихий убийца ML-проектов: модель показывает прекрасные метрики на этапе разработки и с треском проваливается, как только сталкивается с настоящими новыми данными в продакшене.

История

Идея «отложить часть данных для проверки» не нова — статистики использовали её задолго до появления машинного обучения как отдельной дисциплины. Уже в начале XX века при обработке экспериментальных данных было понятно: если строить модель и проверять её на одних и тех же наблюдениях, результат почти всегда получится обманчиво хорошим. Формальный шаг к современному пониманию сделал в 1995 году исследователь Рон Кохави (Ron Kohavi), опубликовавший работу о сравнении hold-out метода, кросс-валидации и бутстрэпа для оценки точности моделей. Эта статья во многом определила словарь, которым до сих пор пользуется вся индустрия: train, validation, test.

Один из самых наглядных исторических примеров дал Netflix Prize — знаменитое соревнование по рекомендательным системам, стартовавшее в 2006 году. Организаторы разделили данные о рейтингах фильмов на несколько частей: набор для обучения, «probe»-набор, по которому участники сами могли проверять промежуточный прогресс (по сути — валидационная выборка), «quiz»-набор, по которому строился публичный рейтинг во время соревнования, и, наконец, полностью скрытый «test»-набор, по которому определялся окончательный победитель уже после завершения гонки. Команды, которые слишком агрессивно подгоняли модели под промежуточный публичный рейтинг, при финальной проверке нередко теряли позиции — ровно тот же эффект утечки, о котором говорилось выше.

С ростом соревновательного машинного обучения на платформах вроде Kaggle эта проблема получила ещё одну яркую иллюстрацию. Участники видят промежуточный результат на так называемом public leaderboard — по сути, ограниченная валидационная выборка. Многие новички начинали подгонять модель именно под публичный лидерборд, а когда в конце соревнования открывался итоговый результат на private leaderboard — настоящей тестовой выборке — их позиция обрушивалась на десятки и сотни мест. Похожая проблема, только в новом обличье, обсуждается и сегодня, в эпоху больших языковых моделей: если тестовые вопросы популярных бенчмарков случайно оказываются внутри тренировочного корпуса текста, на котором обучалась модель, — это та же самая утечка данных, только в промышленном масштабе. Тема, которой посвящён этот урок, была актуальна сто лет назад и остаётся не менее актуальной сегодня.

Зачем нужны три выборки, а не две

Интуиция

Представь спортсмена, который готовится к соревнованиям. Тренировки с постоянной обратной связью от тренера — это train: здесь спортсмен отрабатывает технику, ошибается, исправляется, и именно на этих занятиях меняется его «внутреннее состояние» — сила, техника, выносливость. Контрольные прикидки перед стартом — это validation: по их результатам тренер решает, менять ли план подготовки, добавлять ли нагрузку, какую тактику выбрать на старте. Спортсмен на этих прикидках не улучшает сами мышцы напрямую, но результат прикидки напрямую влияет на решения о подготовке. И, наконец, сами соревнования — это test: туда выходят один раз, результат уже не переиграть, и именно он показывает, чего спортсмен реально стоит.

Если использовать только train и test, дальновидный тренер попадает в ловушку. Ему приходится либо не проверять прогресс до соревнований вовсе (модель обучается вслепую, без возможности выбрать лучшую стратегию), либо использовать сами соревнования как черновую прикидку, снова и снова выходя на старт, чтобы «протестировать» план — но тогда соревнования перестают быть честным показателем: спортсмен начинает адаптироваться именно под них, а не под задачу в целом.

Строгое определение

Определение. Обучающая выборка (train set) — данные, на которых модель напрямую настраивает свои внутренние параметры (веса) в процессе обучения. Валидационная выборка (validation set) — данные, не участвующие в обучении весов напрямую, но используемые для подбора гиперпараметров, выбора архитектуры, отбора признаков и принятия решений об остановке обучения; модель «видит» эти данные лишь косвенно, через решения исследователя. Тестовая выборка (test set) — данные, полностью изолированные от всего процесса разработки модели вплоть до одной финальной оценки, дающей несмещённую (честную) оценку качества модели на новых данных.

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

Разбор примеров

Пример 1 (простой). Базовое разбиение датасета на три части с помощью train_test_split из scikit-learn — функция умеет делить только на две части за раз, поэтому применяется дважды подряд.

from sklearn.model_selection import train_test_split

# Шаг 1: сразу отделяем тестовую выборку — и забываем про неё до конца работы
X_train_full, X_test, y_train_full, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42
)

# Шаг 2: из оставшихся 80% выделяем валидационную выборку
X_train, X_val, y_train, y_val = train_test_split(
    X_train_full, y_train_full, test_size=0.25, random_state=42
)
# 0.25 от оставшихся 80% — это 20% от исходного датасета:
# в итоге пропорция train:val:test = 60:20:20

print(len(X_train), len(X_val), len(X_test))

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

Пример 2 (средний). Подбор гиперпараметра регуляризации по валидационной выборке, а не по тестовой.

from sklearn.linear_model import Ridge

best_score = -1
best_alpha = None

for alpha in [0.01, 0.1, 1.0, 10.0, 100.0]:
    model = Ridge(alpha=alpha)
    model.fit(X_train, y_train)
    val_score = model.score(X_val, y_val)  # смотрим ТОЛЬКО на валидации
    if val_score > best_score:
        best_score = val_score
        best_alpha = alpha

# Тестовую выборку трогаем только сейчас, один раз, в самом конце
final_model = Ridge(alpha=best_alpha)
final_model.fit(X_train, y_train)
test_score = final_model.score(X_test, y_test)
print(f"Лучший alpha={best_alpha}, честная оценка на test: {test_score:.3f}")

Цикл перебора alpha может повторяться сколько угодно раз, потому что каждый раз смотрит только на X_val, y_val. Тестовая выборка появляется в коде ровно один раз, в самом конце — это и есть дисциплина, о которой шла речь во введении.

Пример 3 (сложный, ML). На практике перебор гиперпараметров обычно делают не вручную, а через GridSearchCV, которая сама устраивает внутреннюю кросс-валидацию (подробно — в уроке 306) на переданных ей данных.

from sklearn.model_selection import GridSearchCV

param_grid = {"alpha": [0.01, 0.1, 1.0, 10.0, 100.0]}
grid = GridSearchCV(Ridge(), param_grid, cv=5)  # cv=5 — своя внутренняя валидация
grid.fit(X_train_full, y_train_full)  # test сюда НЕ передаём вообще

print("Лучшие гиперпараметры:", grid.best_params_)
test_score = grid.score(X_test, y_test)  # единственный взгляд на test за весь эксперимент
print(f"Честная оценка на test: {test_score:.3f}")

Здесь X_test физически не существует в переменных, которые видит GridSearchCV — она перебирает гиперпараметры и оценивает их исключительно на внутренних фолдах X_train_full. Тест появляется только в последней строке, когда все решения о модели уже приняты и зафиксированы.

Почему это важно

Многократный просмотр одной и той же выборки при принятии решений — это форма скрытой подгонки, очень похожая на проблему множественных сравнений в статистике (p-hacking): если проверить достаточно много вариантов на одних и тех же данных, рано или поздно какой-то из них покажет отличный результат просто по случайности, а не потому что он действительно лучше. Подгонка под валидацию — это нормальная, ожидаемая часть процесса разработки, ради этого валидация и существует. А вот подгонка под тест обесценивает единственную оставшуюся честную метрику, которая у тебя есть — ту самую, которую увидит заказчик или продакшен-система.

Типичные пропорции разбиения

Интуиция

Здесь работает простой компромисс. Чем больше данных отдать под train, тем лучше модель сможет обучиться — особенно если параметров у неё много. Но чем меньше данных остаётся под validation и test, тем менее надёжна оценка качества: маленькая выборка даёт шумную, скачущую метрику, которой трудно доверять. Правильная пропорция зависит не от какого-то универсального «золотого стандарта», а от абсолютного размера датасета — в конечном счёте важно не процентное соотношение, а то, сколько реальных примеров попадёт в каждую часть.

Строгое определение

Эмпирическое правило. Чем меньше исходный датасет, тем большую относительную долю стоит отводить под validation и test, чтобы оценка качества была статистически надёжной. Чем больше датасет, тем меньшую относительную долю можно позволить себе выделить под них, не теряя абсолютного размера этих выборок — и, соответственно, тем большую долю можно оставить под train.

Разбор примеров

Пример 1 (простой). Небольшой датасет из 1 000 наблюдений — например, данные о пациентах небольшой клиники.

X_train_full, X_test, y_train_full, y_test = train_test_split(
    X, y, test_size=0.15, random_state=42
)
X_train, X_val, y_train, y_val = train_test_split(
    X_train_full, y_train_full, test_size=0.15 / 0.85, random_state=42
)
# итог: примерно 70% train (700), 15% val (150), 15% test (150)

На таком объёме данных 15% под validation и test — это всего 150 примеров каждый, уже на грани статистической надёжности. Если данных ещё меньше, разумнее вообще отказаться от фиксированного разбиения на train/validation в пользу кросс-валидации (урок 306), оставив в стороне лишь честный test.

Пример 2 (средний). Датасет среднего размера — 100 000 записей, типичная задача кредитного скоринга.

X_train_full, X_test, y_train_full, y_test = train_test_split(
    X, y, test_size=0.1, random_state=42
)
X_train, X_val, y_train, y_val = train_test_split(
    X_train_full, y_train_full, test_size=0.1 / 0.9, random_state=42
)
# итог: 80% train (80 000), 10% val (10 000), 10% test (10 000)

10 000 примеров в validation и в test — уже вполне стабильная выборка: метрика вроде accuracy или ROC-AUC (урок 308) на таком объёме почти не «дрожит» от случайного разбиения.

Пример 3 (сложный, ML). Датасет для обучения крупной модели компьютерного зрения — 10 000 000 изображений, задача классификации.

X_train_full, X_test, y_train_full, y_test = train_test_split(
    X, y, test_size=0.01, random_state=42
)
X_train, X_val, y_train, y_val = train_test_split(
    X_train_full, y_train_full, test_size=0.01, random_state=42
)
# итог: примерно 98% train, 1% val (около 100 000), 1% test (около 100 000)

Пропорция 98/1/1 выглядит непривычно по сравнению со «стандартными» 70/15/15, но в абсолютных числах val и test содержат по 100 000 изображений — статистически даже более надёжная оценка, чем 150 примеров из первого примера. При этом почти весь датасет достаётся train, что критично для моделей с миллионами параметров: примерно так устроено, например, разбиение классического датасета ImageNet, где обучающая часть на порядки больше проверочной.

Почему это важно

Слепое копирование пропорции 70/15/15 «потому что так все делают» без оглядки на реальный размер датасета — частая практическая ошибка. Статистическая надёжность метрики зависит от абсолютного числа примеров в выборке, а не от процента: 20 примеров в test дадут метрику, которая может скакнуть на 5% просто из-за одного неверно классифицированного объекта, и такому числу нельзя доверять, даже если формально пропорция «правильная». Решение о пропорции всегда стоит принимать, отталкиваясь от того, сколько объектов реально окажется в каждой части, а не от красивого соотношения на бумаге.

Случайное и стратифицированное разбиение

Интуиция

Представь задачу выявления мошеннических транзакций, где мошеннические операции составляют всего 2% всех данных. Если разбить датасет чисто случайно, по чистой случайности в тестовую выборку может попасть заметно меньше или больше 2% положительных примеров, чем в среднем по датасету — особенно если выборка небольшая. В худшем случае в маленькой тестовой выборке может вообще не оказаться ни одного примера мошенничества, и тогда посчитать recall для класса мошенничества станет попросту невозможно — а ведь это единственный класс, который на самом деле интересует бизнес.

Строгое определение

Определение. Стратифицированное разбиение (stratified split) — способ разделения данных на выборки, при котором сохраняется исходное соотношение классов (или иного признака-страты) в каждой из полученных частей: train, validation и test содержат приблизительно ту же долю каждого класса, что и исходный датасет.

Разбор примеров

Пример 1 (простой). Бинарная классификация с сильным дисбалансом — сравнение случайного и стратифицированного разбиения.

print(y.value_counts(normalize=True))
# 0    0.98
# 1    0.02

# Без стратификации — доли в частях могут заметно «поплыть»
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42
)
print(y_test.value_counts(normalize=True))
# доля класса 1 может оказаться, например, 0.015 или 0.028 —
# случайное отклонение особенно заметно на небольших выборках

# Со стратификацией — доли сохраняются
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42, stratify=y
)
print(y_test.value_counts(normalize=True))
# 0    0.98
# 1    0.02  — доля сохранена практически точно

Пример 2 (средний). Мультиклассовая задача с несколькими редкими классами — например, классификация типов дефектов на производстве, где часть дефектов встречается очень редко.

from sklearn.model_selection import train_test_split

X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42, stratify=y
)

for name, subset in [("исходный", y), ("train", y_train), ("test", y_test)]:
    print(name, subset.value_counts(normalize=True).round(3).to_dict())
# доли всех классов, включая редкие, должны совпадать во всех трёх строках вывода

Проверка через value_counts(normalize=True) до и после разбиения — простой и надёжный способ убедиться, что стратификация действительно сработала, а не просто была указана в коде.

Пример 3 (сложный, ML). Стратификация в задаче регрессии — например, предсказание цены квартиры. Формально stratify работает только с категориальными метками, поэтому непрерывную целевую переменную сначала «бинируют» на квантили.

import pandas as pd

# Разбиваем непрерывную цену на 10 квантильных корзин
y_bins = pd.qcut(y, q=10, labels=False)

X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42, stratify=y_bins
)
# теперь распределение цен в train и test похоже по всем ценовым диапазонам —
# и дешёвые квартиры-студии, и элитная недвижимость представлены пропорционально

Без этого приёма случайное разбиение регрессионной задачи может, например, случайно собрать в test непропорционально много дорогих объектов, и метрика вроде MAE окажется смещённой относительно того, что модель увидит в среднем на реальных данных.

Почему это важно

Без стратификации метрики для редких, но часто самых важных классов (мошенничество, редкое заболевание, брак на производстве) становятся ненадёжными или вовсе неопределёнными: recall, посчитанный по нулю или единичным примерам положительного класса, — это не оценка качества, а по сути случайное число. Стратификация особенно критична именно там, где цена ошибки для редкого класса высока — в медицине, безопасности, финансовом мошенничестве, — то есть ровно там, где честная оценка нужнее всего.

Разбиение временных рядов

Интуиция

Представь, что ты предсказываешь завтрашние продажи магазина по историческим данным за два года. Если просто перемешать все дни случайно и раскидать по train/validation/test, обучающая выборка почти наверняка захватит дни, которые по календарю идут позже, чем некоторые дни в тестовой выборке. Модель, обучаясь, неявно «подсматривает» будущее относительно того момента, который должна предсказывать в тесте — например, через сезонность, тренды или просто через соседние по времени дни со схожими значениями. Метрика на таком тесте окажется обманчиво хорошей, а в реальном продакшене, где будущего заведомо не существует на момент предсказания, модель работает куда хуже.

Строгое определение

Определение. Временной сплит (time-based split) — способ разделения данных, при котором обучающая выборка состоит из наблюдений вплоть до определённого момента времени $t_0$, а валидационная и тестовая выборки — строго из последующих периодов, без случайного перемешивания. Модель никогда не видит в обучении данные, которые по времени наступают позже проверочных.

Разбор примеров

Пример 1 (простой). Неправильный и правильный способ разбить датасет продаж по датам.

# НЕПРАВИЛЬНО: обычный train_test_split перемешивает данные по умолчанию (shuffle=True)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2)
# дни из train и test оказываются вперемешку на временной шкале

# ПРАВИЛЬНО: сортируем по дате и режем строго по времени
df = df.sort_values("date")

split_date = "2023-06-01"
train = df[df["date"] < split_date]
test = df[df["date"] >= split_date]
# train содержит только прошлое относительно test — никакого shuffle

Пример 2 (средний). Использование TimeSeriesSplit из scikit-learn для честной проверки на нескольких временных отрезках подряд.

from sklearn.model_selection import TimeSeriesSplit

tscv = TimeSeriesSplit(n_splits=5)

for train_idx, val_idx in tscv.split(X):
    X_train, X_val = X.iloc[train_idx], X.iloc[val_idx]
    y_train, y_val = y.iloc[train_idx], y.iloc[val_idx]
    # обучающее окно расширяется с каждым разбиением,
    # валидационное окно всегда строго "в будущем" относительно train
    model.fit(X_train, y_train)
    print(model.score(X_val, y_val))

TimeSeriesSplit реализует «расширяющееся окно» (expanding window): на первом разбиении train маленький и включает только самые ранние данные, а validation — следующий по времени короткий отрезок; на каждом следующем разбиении train растёт, «поглощая» предыдущее validation-окно, а новое validation-окно снова сдвигается вперёд по времени. Это одна из разновидностей кросс-валидации, к которой мы подробно вернёмся в уроке 306, — но применительно именно к временным рядам она обязана уважать хронологию.

Пример 3 (сложный, ML). Walk-forward валидация — имитация того, как модель будет реально работать в продакшене, с периодическим переобучением.

import pandas as pd

months = sorted(df["month"].unique())

results = []
for i in range(12, len(months)):  # начинаем, когда накопился хотя бы год истории
    train_months = months[:i]
    test_month = months[i]

    train = df[df["month"].isin(train_months)]
    test = df[df["month"] == test_month]

    model.fit(train[features], train["target"])
    score = model.score(test[features], test["target"])
    results.append((test_month, score))
    # модель переобучается каждый месяц заново, используя всю накопленную историю,
    # и проверяется строго на следующем, ещё не виденном месяце

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

Почему это важно

Временные ряды почти всегда автокоррелированы: значение сегодня сильно похоже на значение вчера или на то же время год назад. Случайное перемешивание разрушает эту структуру для честной оценки, но не разрушает саму зависимость в данных — модель по-прежнему может использовать эту зависимость, просто уже нечестным образом, подсматривая соседние по времени точки из train при предсказании test. В реальных проектах — от прогноза продаж до предсказания курсов валют — это одна из самых частых причин, почему офлайн-метрики выглядят прекрасно, а модель, выведенная в продакшен, начинает ошибаться заметно сильнее уже в первую неделю работы.

Утечка данных (data leakage): главная практическая ошибка

Интуиция

Утечку данных проще всего понять через аналогию со списыванием: представь, что на экзамене тебе случайно попался вариант с ответами, подсмотренными из версии, которую напишут студенты завтра. Формально ты «сдал» экзамен блестяще. Но это не значит, что ты знаешь материал — это значит, что тебе повезло увидеть то, чего видеть было нельзя. Data leakage — это ровно та же ситуация применительно к модели: в процесс обучения или отбора модели каким-то образом просачивается информация, которая в реальный момент предсказания заведомо недоступна. Метрика на этапе разработки выглядит отлично, а в продакшене иллюзия немедленно рушится.

Строгое определение

Определение. Утечка данных (data leakage) — ситуация, при которой информация из валидационной или тестовой выборки, либо информация, недоступная в реальный момент предсказания, тем или иным образом влияет на процесс обучения или выбора модели, что приводит к завышенной, недостоверной оценке качества на этапе разработки.

Разбор примеров

Пример 1 (масштабирование признаков до разбиения — классическая ошибка). Если посчитать среднее и стандартное отклонение для нормализации по всему датасету, а потом разбить его на train/test, статистики теста незаметно «просачиваются» в обучение.

from sklearn.preprocessing import StandardScaler

# НЕПРАВИЛЬНО: сначала масштабируем ВСЕ данные вместе
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)  # среднее и std посчитаны по ВСЕМ данным, включая test!

X_train, X_test, y_train, y_test = train_test_split(X_scaled, y, test_size=0.2)
# модель на train косвенно "знает" статистики теста — это и есть утечка

# ПРАВИЛЬНО: сначала разбиваем, потом обучаем scaler только на train
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)

scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)       # fit — только на train
X_test_scaled = scaler.transform(X_test)              # transform — используем статистики train

# Ещё надёжнее — обернуть всё в Pipeline, чтобы исключить ошибку физически:
from sklearn.pipeline import Pipeline
from sklearn.linear_model import Ridge

pipe = Pipeline([
    ("scaler", StandardScaler()),
    ("model", Ridge(alpha=1.0)),
])
pipe.fit(X_train, y_train)   # scaler.fit происходит только внутри, только на X_train
pipe.score(X_test, y_test)

Пример 2 (дублирующиеся или связанные записи в разных выборках). Если у одного и того же объекта (пациента, пользователя, устройства) в датасете несколько строк, случайный сплит может раскидать их по разным выборкам, и модель будет фактически «запоминать» конкретный объект, а не учиться обобщать.

from sklearn.model_selection import GroupShuffleSplit

# Медицинский датасет: у одного пациента может быть по 5 снимков.
# Обычный train_test_split легко раскидает снимки одного пациента
# и в train, и в test одновременно — модель "узнает" пациента по стилю снимка,
# а не научится распознавать патологию в целом.

gss = GroupShuffleSplit(test_size=0.2, n_splits=1, random_state=42)
train_idx, test_idx = next(gss.split(X, y, groups=patient_id))
X_train, X_test = X.iloc[train_idx], X.iloc[test_idx]
y_train, y_test = y.iloc[train_idx], y.iloc[test_idx]
# теперь ВСЕ снимки одного пациента гарантированно оказываются в одной выборке

Пример 3 (агрегированные признаки, посчитанные по всему датасету). Частый источник утечки при feature engineering — статистики вроде «средняя цена по городу» или target encoding, вычисленные до разбиения на train/test.

# НЕПРАВИЛЬНО: считаем среднюю цену по городу, используя ВСЕ данные, включая test
city_mean_price = df.groupby("city")["price"].mean()
df["city_avg_price"] = df["city"].map(city_mean_price)
X_train, X_test, y_train, y_test = train_test_split(df, df["price"], test_size=0.2)
# признак "city_avg_price" уже неявно содержит информацию о ценах тестовых объектов

# ПРАВИЛЬНО: сначала разбиваем, статистику считаем только по train
X_train, X_test, y_train, y_test = train_test_split(df, df["price"], test_size=0.2, random_state=42)

city_mean_price = X_train.groupby("city")["price"].mean()
X_train["city_avg_price"] = X_train["city"].map(city_mean_price)
X_test["city_avg_price"] = X_test["city"].map(city_mean_price)
# для городов, которых не было в train, получим NaN —
# и это честно отражает то, что произойдёт в реальном продакшене с новым городом

Пример 4 (использование будущей или производной от таргета информации). Иногда утечка сидит прямо в определении признака, а не в порядке кода. Например, в задаче «предсказать, отменит ли клиент заказ» признак «число дней от заказа до отмены» технически существует только у уже отменённых заказов — он напрямую производится от того самого события, которое модель должна предсказывать, и просто не может существовать в момент реального предсказания.

# НЕПРАВИЛЬНО: признак существует только для уже случившегося события
df["days_to_cancellation"] = (df["cancel_date"] - df["order_date"]).dt.days
# для заказов, которые НЕ были отменены, этого значения просто не может быть —
# наличие непустого значения само по себе выдаёт ответ модели

# ПРАВИЛЬНО: используем только признаки, доступные в момент оформления заказа
features = ["order_amount", "customer_tenure_days", "payment_method", "delivery_region"]

Почему это важно

Data leakage — самая коварная ошибка именно потому, что она не проявляется как разрыв между train и validation, который легко диагностировать (урок 304). Наоборот, при утечке валидационная и даже тестовая метрика могут быть отличными — модель выглядит рабочей, проходит все проверки, но при первом столкновении с настоящими новыми данными в продакшене начинает ошибаться в разы сильнее, чем обещали цифры на этапе разработки. Именно утечка данных — одна из самых частых причин, по которым реальные ML-проекты срываются на стадии внедрения, стоя команде месяцев впустую потраченной работы и доверия заказчика.

Практика: 30 заданий

Базовые задания (1–10)

Задание 1: У тебя 10 000 объектов. Используешь пропорцию 70/15/15. Сколько объектов попадёт в train, сколько в validation и сколько в test?


Задание 2: Почему нельзя оценивать качество модели на тех же данных, на которых она обучалась?


Задание 3: Чем принципиально отличается роль валидационной выборки от роли тестовой?


Задание 4: Датасет содержит всего 300 примеров. Какую пропорцию разумнее выбрать — 60/20/20 или 98/1/1? Почему?


Задание 5: Что делает вызов train_test_split(X, y, test_size=0.2, random_state=42)? За что отвечает параметр random_state?


Задание 6: Датасет содержит 5 000 000 изображений. Ты выделяешь всего 1% на test. Сколько это изображений и достаточно ли этого?


Задание 7: Можно ли подбирать гиперпараметры, ориентируясь на метрику на test выборке? Почему?


Задание 8: Объясни простыми словами, что такое стратифицированное разбиение.


Задание 9: У тебя задача бинарной классификации, положительный класс составляет всего 3% данных. Стоит ли использовать stratify при разбиении? Почему?


Задание 10: Почему для временных рядов нельзя использовать обычный train_test_split со стандартным перемешиванием (shuffle=True по умолчанию)?


Средние задания (11–20)

Задание 11: Дан код:

scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)
X_train, X_test, y_train, y_test = train_test_split(X_scaled, y, test_size=0.2)

Найди ошибку и объясни, к чему она приведёт.


Задание 12: Дан датасет о продажах товаров с колонкой «дата». Опиши, как правильно разбить его на train/test.


Задание 13: У тебя мультиклассовая задача (5 классов, часть из них редкая). Как проверить, что стратификация действительно сохранила пропорции классов после разбиения?


Задание 14: Что произойдёт при выполнении этого кода на сильно несбалансированных данных, и почему это плохо?

X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.05)
print(y_test.value_counts())

Задание 15: Объясни разницу между train_test_split(shuffle=True) и TimeSeriesSplit применительно к прогнозированию курса валют.


Задание 16: В интернет-магазине есть датасет отзывов, где один и тот же пользователь написал по 20 отзывов. Почему обычный случайный сплит по отзывам рискован?


Задание 17: Дан код с target encoding:

city_mean = df.groupby("city")["price"].mean()
df["city_avg"] = df["city"].map(city_mean)
X_train, X_test, y_train, y_test = train_test_split(df, df["price"], test_size=0.2)

Что здесь не так?


Задание 18: Почему пропорция 80/10/10 обычно используется для больших датасетов глубокого обучения, а не, например, 33/33/33?


Задание 19: В чём разница между «модель переобучилась на train» (урок 304) и «данные теста утекли» (этот урок) — ведь оба явления приводят к разрыву между заявленным и реальным качеством?


Задание 20: Опиши, как использование sklearn.pipeline.Pipeline помогает гарантированно избежать утечки при масштабировании признаков.


Продвинутые задания (21–30)

Задание 21: У тебя задача кредитного скоринга. Один из признаков — «статус одобрения заявки в течение следующих 30 дней». Это утечка данных? Почему?


Задание 22: Датасет содержит записи о пациентах с 2015 по 2023 год, причём заболевание, которое ты предсказываешь, стало диагностироваться заметно реже после 2020 года благодаря новому методу скрининга. Как это влияет на выбор способа разбиения?


Задание 23: Спроектируй разбиение для задачи «предсказать, уйдёт ли клиент банка в следующем квартале», если данные представляют собой записи (клиент, месяц) за 3 года. Опиши пошагово.


Задание 24: Почему для сильно несбалансированной задачи (0.1% положительного класса) обычная пропорция 80/10/10 может быть недостаточной для надёжной оценки на test, даже если весь датасет большой — 1 000 000 записей?


Задание 25: Дан пайплайн, где сначала делают oversampling (например, SMOTE) для балансировки классов, а затем train_test_split. В чём проблема?


Задание 26: Объясни, почему GridSearchCV(cv=5) не требует отдельно выделенной вручную валидационной выборки.


Задание 27: Ты используешь TimeSeriesSplit(n_splits=5). Объясни, почему в первом разбиении обучающая выборка маленькая, а в последнем — большая.


Задание 28: Опиши сценарий утечки данных, специфичный именно для задач обработки текста (NLP), не связанный с масштабированием числовых признаков.


Задание 29: У тебя есть три готовые модели с разными гиперпараметрами. Опиши полный честный протокол выбора лучшей модели и получения финальной метрики качества.


Задание 30 (на подумать): Почему в современных исследованиях больших языковых моделей особое внимание уделяют «контаминации» (contamination) тестовых бенчмарков в тренировочных данных, и как это связано с темой этого урока?

Частые ошибки

  • Масштабирование или нормализация признаков до разбиения данных. Самая частая ошибка новичков: fit_transform вызывается на всём датасете, а затем данные делятся на train/test — статистики теста просачиваются в обучение. Решение — разбивать данные первым делом и обучать любой препроцессинг только на train, а лучше сразу использовать Pipeline.

  • Подбор гиперпараметров по метрике на test, а не на validation. Каждый повторный взгляд на test при принятии решений о модели — это форма утечки, которая делает финальную метрику недостоверной, даже если формально ни одна строка кода не выглядит «неправильной».

  • Отсутствие стратификации при сильном дисбалансе классов. Случайное разбиение при небольшой доле редкого класса может исказить его представленность в валидации и тесте вплоть до полного отсутствия — метрики вроде recall становятся ненадёжными именно там, где они важнее всего.

  • Случайное перемешивание временных рядов вместо хронологического разбиения. Использование train_test_split со стандартным shuffle=True на данных с временной структурой создаёт утечку через автокорреляцию: модель неявно подсматривает соседние по времени точки.

  • Дублирующиеся или связанные записи в разных выборках. Если несколько строк относятся к одному и тому же объекту реального мира (пациенту, пользователю, устройству), случайный сплит может раскидать их по train и test, позволяя модели «узнавать» объект, а не обобщать закономерность.

  • Вычисление агрегированных признаков (target encoding, средние по группам) на всём датасете, включая test. Признак, посчитанный «заранее» по полному датасету, неявно содержит информацию о тестовых объектах — статистику всегда нужно считать только по train и переносить на test как есть.

  • Повторное переразбиение данных в поисках «удобного» результата. Если результат на test не понравился и датасет перемешивают заново в надежде получить цифры получше — это скрытая форма подгонки под тест (аналог data dredging в статистике), которая обесценивает саму идею честной оценки.

Главное запомнить

  • Данные делят на три части: train — для обучения весов модели, validation — для подбора гиперпараметров и архитектурных решений, test — для единственной финальной честной оценки.

  • Валидацию можно смотреть многократно в процессе разработки — это её прямое назначение; тестовую выборку смотрят один раз, в самом конце.

  • Типичные пропорции — 70/15/15 или 80/10/10, но выбор должен опираться на абсолютное число примеров в каждой части, а не на процент как таковой.

  • Чем меньше датасет, тем большую относительную долю нужно отводить под validation и test ради статистической надёжности оценки.

  • Стратифицированное разбиение сохраняет исходные пропорции классов во всех частях — критично при дисбалансе, особенно для редких, но важных классов.

  • Для непрерывной целевой переменной стратификацию можно приблизить, предварительно разбив таргет на квантильные интервалы.

  • Временные ряды разбивают строго по хронологии — train содержит только более ранние наблюдения, чем validation и test, без случайного перемешивания.

  • Data leakage — ситуация, при которой запрещённая информация (из теста, из будущего, из самого таргета) просачивается в процесс обучения или отбора модели, завышая метрику на этапе разработки.

  • Самый частый сценарий утечки — предобработка данных (масштабирование, target encoding, векторизация текста) до разбиения на выборки; лекарство — всегда сначала разбивать, потом считать статистики только на train.

  • Утечка данных особенно коварна тем, что не проявляется как разрыв между train и validation — метрики могут выглядеть отлично вплоть до столкновения с настоящими новыми данными в продакшене.

Связь с темами курса

Этот урок напрямую продолжает урок 304 про переобучение и недообучение: там мы научились обнаруживать переобучение, сравнивая ошибку на train и на честной проверочной выборке, — но сам этот диагноз в принципе не сработает, если проверочная выборка выбрана или использована неправильно. Правильное разбиение на train/validation/test — это фундамент, без которого вся диагностика переобучения из прошлого урока превращается в самообман. А утечка данных, которой была посвящена значительная часть этого урока, — это ровно тот механизм, который незаметно подрывает даже, казалось бы, аккуратно выстроенный процесс проверки.

Следующий урок, 306 «Кросс-валидация: как честно оценить модель», развивает идею, намеченную здесь при обсуждении маленьких датасетов и разбора TimeSeriesSplit: вместо единственного фиксированного разбиения на train/validation данные можно разбивать несколько раз по-разному и усреднять результат, получая более устойчивую оценку качества модели. Идея валидационной выборки, с которой ты познакомился в этом уроке, там превратится в целое семейство техник — k-fold, стратифицированная k-fold, и та самая временная кросс-валидация, первый пример которой уже встретился в блоке про временные ряды.

Интересные факты

  • Знаменитое соревнование Netflix Prize (2006–2009) использовало не два и не три, а четыре логических набора данных для оценки моделей рекомендаций: обучающий, «probe» (для самостоятельной проверки участниками), «quiz» (по которому строился публичный рейтинг во время соревнования) и полностью скрытый «test», который определял финального победителя, — практическая иллюстрация принципов этого урока за полтора десятилетия до того, как они стали общим местом в индустрии.

  • Работа Рона Кохави 1995 года о сравнении методов оценки моделей («A Study of Cross-Validation and Bootstrap for Accuracy Estimation and Model Selection») остаётся одной из самых цитируемых статей в области машинного обучения — редкий случай, когда методологическая, а не алгоритмическая работа получила такое влияние.

  • В 2023–2024 годах вокруг крупных языковых моделей развернулась целая дисциплина исследований, посвящённая «контаминации бенчмарков» — обнаружению случаев, когда тестовые вопросы популярных наборов данных для оценки моделей оказались внутри тренировочного интернет-корпуса; некоторые известные бенчмарки пришлось пересматривать именно по этой причине.

  • Термин «data leakage» в статистике и эконометрике использовался задолго до бума машинного обучения — под другими названиями похожая проблема обсуждалась применительно к финансовому прогнозированию ещё в середине XX века, когда аналитики строили модели по историческим данным биржи, случайно используя информацию, недоступную на момент реальной торговли.

Лайфхаки

  • Возьми за правило: тестовая выборка отделяется от данных первой строкой кода, ещё до любой предобработки — масштабирования, кодирования категорий, отбора признаков. Всё, что происходит с данными после этой строки, должно either применяться только к train, либо — как transform без повторного fit — переноситься на test.

  • Используй sklearn.pipeline.Pipeline для любой цепочки «препроцессинг + модель» — это не просто удобство, а страховка от целого класса ошибок утечки данных, потому что fit внутри Pipeline физически не может «увидеть» тестовые данные раньше времени.

  • Если в датасете есть естественная группировка объектов (пользователь, пациент, устройство, сессия), всегда проверяй, не может ли один и тот же реальный объект оказаться сразу в нескольких выборках — и используй GroupShuffleSplit или GroupKFold, если это так.

  • На маленьких датасетах (сотни–первые тысячи примеров) не держись за единственное фиксированное разбиение на train/validation — переходи к кросс-валидации (урок 306), оставляя фиксированной только тестовую выборку.

  • Для отладки утечки данных полезен простой мысленный тест: «Мог бы я получить значение этого признака в реальный момент предсказания, до того как узнаю ответ?» Если нет — признак нужно убрать или пересчитать только по доступной на тот момент истории.

  • Фиксируй random_state для воспроизводимости экспериментов, но время от времени проверяй устойчивость результата, меняя семя случайности, — если метрика сильно скачет от одного разбиения к другому, это сигнал, что выборка недостаточно велика или стратификация настроена неверно.

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

Понял тему? Закрепи в боте! 🚀

Попрактикуйся на задачах и получи персональные рекомендации от AI

💪 Начать тренировку
💬 Есть вопрос? Спроси бота!