Урок курса

train_test_split без рассинхронизации X и y

Машинное обучение с нуля на Python: первая модель

В прошлом уроке мы загружали датасет diabetes и обучали модель на первых 400 строках, а оставшиеся строки оставались «за бортом» — без случайного перемешивания и без формальной тестовой выборки. Теперь сделаем это правильно: train_test_split одним вызовом случайно перемешает строки и разобьёт и X, и y синхронно — так, что ни одна тестовая строка не попадёт в обучение.

train_test_split: один вызов для X и y, параметры test_size и random_state

Функция train_test_split принимает произвольное количество массивов или DataFrame-ов и разбивает каждый из них одинаково — с одной и той же случайной перестановкой строк. Именно поэтому X и y передаются вместе:

from sklearn.datasets import load_diabetes
from sklearn.model_selection import train_test_split

bunch = load_diabetes(as_frame=True)
X = bunch.data
y = bunch.target

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

print(X_train.shape, X_test.shape)
print(y_train.shape, y_test.shape)

Датасет diabetes содержит 442 строки. test_size=0.2 означает, что 20 % строк попадут в тест. Scikit-learn округляет дробное число строк вверх, поэтому в тест уйдут 89 строк, в обучение останутся 353. Точное число всегда можно проверить через X_test.shape[0] — ручные расчёты здесь лишние.

random_state=0 фиксирует генератор случайных чисел. Функция внутри перемешивает индексы строк перед нарезкой, и конкретный порядок этого перемешивания определяется seed-ом. Запустите код дважды с random_state=0 — получите идентичные X_train и X_test. Если не передать random_state (или передать None), функция использует текущее состояние глобального генератора: разбиение обычно меняется при каждом новом запуске, и воспроизвести его позже неудобно. Фиксированное целое число делает пример повторяемым без лишних усилий.

Функция возвращает результаты поочерёдно для каждого переданного массива: сначала X_train и X_test для X, затем y_train и y_test для y. Это и есть правильный порядок распаковки в примере выше. Если передать три массива, получите шесть переменных в том же принципе. Важно распаковать их в правильном порядке — иначе X_test окажется там, где ожидался y_train, и никакой ошибки Python не бросит.

Согласованность индексов X и y и ошибка раздельного разбиения

После совместного вызова train_test_split(X, y, ...) каждая строка X_train гарантированно соответствует элементу y_train с тем же индексом — при условии, что исходные X и y были согласованы до вызова. Убедиться в этом можно прямо:

print((X_train.index == y_train.index).all())  # True
print((X_test.index  == y_test.index).all())   # True

Когда X и y — pandas-объекты, train_test_split сохраняет оригинальные метки строк, перенося их вместе с данными. Сравнение .index == .index показывает, что одна и та же перестановка была применена к обоим массивам синхронно. Такая проверка полезна: она ловит многие случаи рассинхронизации — например, если X и y случайно имеют разный порядок строк до разбиения.

Теперь представьте альтернативу — два отдельных вызова:

# Опасный антипаттерн
X_train, X_test = train_test_split(X, test_size=0.2, random_state=0)
y_train, y_test = train_test_split(y, test_size=0.2, random_state=0)

При одинаковом random_state и одинаковой длине входов перестановка формально совпадёт. Но стоит кому-то поменять random_state во втором вызове, добавить фильтрацию между строками или случайно передать другой порядок — и строки рассинхронизируются. Модель начнёт учиться на парах (признаки пациента A, показатель прогрессирования заболевания пациента B), и Python не бросит никакого исключения. Ошибка может пройти незамеченной, если не проверить индексы явно.

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

Тестовая выборка только для оценки: запрет передачи X_test в fit

После разбиения у нас четыре переменных, и их назначение строго разделено: X_train и y_train идут в обучение, X_test и y_test — только в оценку после обучения. Правило записывается так:

model.fit(X_train, y_train)          # правильно
# model.fit(X_test, y_test)          # ошибка по смыслу
# model.fit(pd.concat([X_train, X_test]), ...)  # тоже ошибка

Почему это важно? fit подстраивает параметры модели под те строки, которые она видит. Если в fit попадают строки из X_test, модель обучается на тех же объектах, на которых её потом будут оценивать. Тестовая выборка перестаёт быть независимой, и её метрика начинает отражать не способность модели обобщаться, а то, насколько хорошо она подогналась под конкретные строки.

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

Тестовая выборка должна имитировать данные, которые модель встретит после деплоя — данные, которых она никогда не видела. Как только хотя бы одна строка из теста попала в fit, эта гарантия нарушена для всего теста целиком.

Попробуйте решить

После train_test_split студент вызвал model.fit(X_test, y_test), а затем оценил модель на тех же X_test и y_test. В чём главная проблема такого подхода?

Продолжить с проверкой и прогрессом

Откройте интерактивный раннер с заданиями урока.

Перейти к интерактивному уроку