Введение в валидацию модели: как разделять данные на train, validation и test

Модель хорошо предсказывает данные, на которых училась, но это ещё не показывает её качество на новых объектах. Отложенная проверка нужна, чтобы отделить обучение от выбора настроек и от итоговой оценки. Главная ошибка новичка — многократно смотреть на test и постепенно подстраивать решение под него.
Разберём три выборки и небольшой воспроизводимый пример на scikit-learn. Используется учебный набор Wine: это задача классификации, а не прогноз продаж и не пример медицинской диагностики. Цель — показать честную последовательность действий, а не получить максимальную цифру на маленьком датасете.
У каждой части данных своя роль
Train используется для обучения параметров модели и преобразований признаков. Validation помогает выбирать гиперпараметры и сравнивать варианты. Test остаётся нетронутым до момента, когда решение и правила оценки уже выбраны.
Если вы пять раз поменяли модель после просмотра test, последняя цифра уже не является независимой финальной проверкой. Название переменной X_test не защищает от утечки информации через собственные решения.
В авторском примере сначала откладываем 20% объектов для test. От оставшихся 80% берём четверть на validation: это ещё 20% исходного набора. В итоге получаем примерно 60/20/20. Пропорции выбраны для обучения работе с API, а не являются универсальным стандартом для ML.
Не всякие данные можно случайно перемешать
Для независимых объектов классификации stratify помогает сохранить соотношение классов при разделении. random_state фиксирует случайное разбиение для воспроизводимости. Это не гарантирует равную сложность частей и не исправляет ошибки исходных данных.
Если несколько строк принадлежат одному человеку, устройству или заказу, случайное разделение строк может поместить связанные наблюдения в обе части. Если прогнозируется будущее по прошлому, произвольное перемешивание времени способно показать модели информацию, которой в реальном моменте прогноза ещё нет. Для таких задач выбирают схему с учётом групп или времени, а не механически копируют наш код.
До обучения проверьте дубликаты, идентификаторы сущностей и доступность каждого признака в момент реального предсказания. Столбец «итоговый статус после закрытия заявки» нельзя использовать для раннего прогноза этого же статуса только потому, что он присутствует в выгрузке.
Hold-out и кросс-валидация
Hold-out — одно выделенное разделение для проверки. Оно просто и удобно для первого рабочего эксперимента, но результат зависит от того, какие объекты попали в проверочную часть.
При k-fold cross-validation данные разработки делят на несколько частей: модель поочерёдно обучается на остальных и оценивается на очередной отложенной части. Для классификации часто используют стратифицированную схему. Сравнивают не только среднее качество, но и разброс между частями.
Кросс-валидация особенно полезна при сравнении настроек, когда одного hold-out мало для устойчивого выбора. Но она требует нескольких обучений. Число фолдов зависит от данных и ресурсов; пять — частый рабочий вариант, а не обязательное число для любой задачи.
В нашем коде гиперпараметр выбирается по отдельной validation. Альтернативой может стать кросс-валидация на данных разработки. В обоих случаях окончательный test не используется для выбора C, признаков и способа масштабирования.
Преобразования обучаются только на train
Документация scikit-learn по типичным ошибкам предупреждает: даже если целевая переменная не попала в признаки, утечка возможна через подготовку данных. Например, масштабирование по среднему и разбросу всего датасета использует информацию из отложенных объектов.
Правильная схема: сначала разделение, затем fit преобразования на обучающей части; к validation и test применяют уже обученное transform. То же внимание требуется заполнению пропусков и отбору признаков.
Pipeline связывает StandardScaler и LogisticRegression так, что вызов fit обучает их на переданной обучающей выборке, а predict последовательно применяет подготовленные преобразования и модель. При кросс-валидации передавайте весь pipeline, а не заранее преобразованную по всем данным матрицу.
Метрика должна соответствовать вопросу
Accuracy показывает долю правильных ответов. При сильном дисбалансе она способна скрыть плохое распознавание редкого класса. Поэтому дополнительно смотрите матрицу ошибок и показатели по классам.
В учебном многоклассовом примере выбираем C по F1 macro. Scikit-learn рассчитывает F1 отдельно по классам и усредняет без учёта их численности при average="macro". Это выбранный критерий примера; для прикладной задачи цена разных ошибок может потребовать другой метрики.
Порог решения и метрику нужно определять до финального test. Высокая F1 не объясняет сама по себе, приемлемы ли конкретные ошибки для пользователя продукта.
Минимальный рабочий пример
Ниже полный скрипт: сначала выбирается C по validation, затем новый pipeline обучается на объединённых данных разработки и один раз оценивается на test. Набор содержит 178 объектов; из-за целых размеров разбиение даёт 106 объектов train, 36 validation и 36 test.
from sklearn.datasets import load_wine
from sklearn.model_selection import train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score, f1_score, confusion_matrix
X, y = load_wine(return_X_y=True)
X_dev, X_test, y_dev, y_test = train_test_split(
X, y, test_size=0.20, stratify=y, random_state=42
)
X_train, X_val, y_train, y_val = train_test_split(
X_dev, y_dev, test_size=0.25, stratify=y_dev, random_state=43
)
scores = []
for c in [0.1, 1.0, 10.0]:
model = make_pipeline(StandardScaler(), LogisticRegression(C=c, max_iter=2000))
model.fit(X_train, y_train)
score = f1_score(y_val, model.predict(X_val), average="macro")
scores.append((score, c))
best_score, best_c = max(scores, key=lambda pair: pair[0])
final_model = make_pipeline(StandardScaler(), LogisticRegression(C=best_c, max_iter=2000))
final_model.fit(X_dev, y_dev)
prediction = final_model.predict(X_test)
print("sizes", len(y_train), len(y_val), len(y_test))
print("validation", scores, "chosen_C", best_c)
print("test_accuracy", accuracy_score(y_test, prediction))
print("test_f1_macro", f1_score(y_test, prediction, average="macro"))
print("confusion_matrix", confusion_matrix(y_test, prediction).tolist())
Этот пример выполнен с Python 3.12 и scikit-learn 1.9.1. В данном запуске validation выбрала C = 0.1; accuracy и F1 macro на test получились равны 1.0. Это результат одного фиксированного разбиения маленького учебного набора, а не обещание такого качества на реальных данных.
Обратите внимание на повторное обучение: после выбора C модель обучена на X_dev, включающем train и validation, но не test. Мы не подбираем новое C после просмотра финального результата. При равных оценках код оставляет первый из перечисленных вариантов — это явное правило примера.
Что сохранить вместе с итоговой цифрой
Запишите версию библиотеки, способ разделения, random_state, выбранные признаки, pipeline, метрику и гиперпараметр. Для реального проекта добавьте информацию о времени и группах объектов. Без этих условий цифру трудно воспроизвести и правильно сравнить с новым экспериментом.
Если после test найдена содержательная ошибка, её нужно исправить и честно пересмотреть схему оценки. Не выдавайте использованный для доработок test за всё ещё независимую неизвестную выборку. Надёжная валидация начинается с дисциплины работы с данными, а не с выбора самой сложной модели.



