Урок курса

Утечка цели и неправильное разделение

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

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

Утечка цели: признак, производный от будущего ответа

Представьте задачу: предсказать, сдаст ли студент экзамен (passed). Датафрейм содержит три признака — hours_studied, attendance_pct и final_score. Всё выглядит разумно, пока не задать один вопрос: будет ли значение final_score известно до того, как станет известен ответ?

Очевидно, нет. Итоговый балл появляется именно тогда, когда экзамен сдан или провален — то есть одновременно с целевой переменной или даже после неё. Признак final_score не просто коррелирует с passed; он фактически и есть passed, выраженный по-другому. Модель, обученная с таким признаком, «предсказывает» исход, зная его заранее.

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

Диагностика строится не на значении ошибки, а на происхождении признака. Для каждого столбца X нужно спросить: «Это значение существует раньше, чем возникает вопрос, на который мы отвечаем?» Для hours_studied ответ — да, студент занимался до экзамена. Для attendance_pct — да, посещаемость накапливается в течение семестра. Для final_score — нет, этот факт появляется вместе с ответом. Именно поэтому низкая или высокая ошибка на test сама по себе не доказывает наличие или отсутствие утечки: ошибка — следствие, а не причина.

Другой пример: при прогнозе страхового случая (claim=1/0) признак «сумма выплаты» — явная утечка. Размер выплаты становится известен лишь после того, как случай произошёл и урегулирован; в момент, когда модель должна предсказать исход, этого числа ещё не существует. Дополнительный аргумент не нужен: само время появления данных делает признак недопустимым.

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

Удаление признака-нарушителя до разделения данных

Обнаружив утечку, нужно убрать признак из X — и сделать это до вызова train_test_split. Порядок важен.

import pandas as pd
from sklearn.model_selection import train_test_split

df = pd.DataFrame({
    'hours_studied':  [10, 5, 8, 3, 7],
    'attendance_pct': [90, 60, 80, 40, 75],
    'final_score':    [88, 55, 78, 42, 72],
    'passed':         [1, 0, 1, 0, 1]
})

# Шаг 1: убираем цель и признак-нарушитель
X = df.drop(columns=['passed', 'final_score'])  # утечка удалена
y = df['passed']

# Шаг 2: только после этого делим
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)

print(list(X.columns))  # ['hours_studied', 'attendance_pct']

Почему именно до train_test_split? Разберём, что пошло бы не так, если бы мы оставили final_score в X и попытались исправить ситуацию уже после разделения — только для обучающей части.

Представим, что X сформирован с final_score и уже разбит на X_train и X_test. Тогда попытка удалить столбец только из train и обучить модель выглядела бы так:

# Плохой сценарий — только для иллюстрации, не повторять
X_bad = df.drop(columns=['passed'])          # final_score оставлен
X_train_bad, X_test_bad, y_train, y_test = train_test_split(
    X_bad, y, test_size=0.2, random_state=42
)

X_train_fixed = X_train_bad.drop(columns=['final_score'])  # убрали только из train

from sklearn.tree import DecisionTreeClassifier
model = DecisionTreeClassifier()
model.fit(X_train_fixed, y_train)    # fit видел 2 признака
model.predict(X_test_bad)            # X_test_bad содержит 3 признака — несовпадение схем

Scikit-learn запоминает имена и количество столбцов при fit. Когда predict получает набор с другими столбцами, вызов завершается исключением — модель не может сделать ни одного предсказания. Это не тихое искажение оценки, а полная блокировка работы.

Можно, конечно, удалить final_score из обеих частей после разделения — технической ошибки тогда не будет. Но логика становится запутанной: нужно не забыть обработать каждую часть отдельно, и риск пропустить одну из них вполне реален. Гораздо надёжнее сформировать корректный X один раз, до любого деления.

Ещё опаснее сценарий, когда final_score остаётся в X целиком и попадает в fit. Насколько сильно просядет ошибка, зависит от модели и от того, насколько точно признак кодирует цель. Но даже если ошибка снизится лишь умеренно, оценка становится недостоверной: в реальном запросе final_score будет недоступен, и модель окажется бесполезной с первого же предсказания. Утечку обнаруживают не по величине ошибки, а задав вопрос о доступности признака в момент прогноза — именно этот вопрос позволяет отделить допустимые признаки от недопустимых.

Правило простое: X должен содержать только те столбцы, которые будут доступны в момент настоящего прогноза. Всё остальное — вон до split.

Случайное перемешивание недопустимо: время и повторные наблюдения

Удаление признака-нарушителя решает проблему утечки через содержимое столбца. Но бывают ситуации, где проблема возникает не из-за признака, а из-за самого способа разделения строк.

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

Здесь уместно вспомнить диагностический вопрос из предыдущих секций: «Будет ли это значение известно до момента прогноза?» Он касается не только столбцов, но и самих строк. Когда мы случайно помещаем в train строку из будущего, мы дарим модели знание, которого у неё не было бы в реальной ситуации. Признаки вроде погоды тоже стоит рассматривать критически: фактическая погода будущего дня в момент прогноза неизвестна — её можно использовать только в виде прогноза, доступного заранее.

Корректная стратегия для временных данных — хронологический разрез без перемешивания: все ранние записи идут в train, все поздние — в test. Граница проходит по дате.

# sales_df — датафрейм с колонкой 'date'
sales_df_sorted = sales_df.sort_values('date')
split_idx = int(len(sales_df_sorted) * 0.8)
train_df = sales_df_sorted.iloc[:split_idx]
test_df  = sales_df_sorted.iloc[split_idx:]

Никакого shuffle=True, никакого random_state для выбора строк — только позиция во времени определяет принадлежность.

Повторные наблюдения одного объекта. Другая ситуация: таблица медицинских измерений, где каждому пациенту соответствует несколько строк (визиты). Если задача — оценить, как модель будет работать с ранее не встречавшимися пациентами, то случайное разделение строк этого не обеспечивает: часть визитов пациента №7 попадает в train, часть — в test. Модель обучалась на строках этого пациента — его профиль, хронические заболевания — и применяет это знание к тестовым строкам того же человека. Ошибка может оказаться низкой не потому, что модель научилась обобщать на новых людей, а потому что она уже видела данного пациента.

Для такой постановки правильная стратегия — разделять по объектам целиком: все строки пациента либо в train, либо в test. Тогда тестовые объекты гарантированно незнакомы модели и оценка отражает реальную цель.

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

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

Банк в день выдачи кредита прогнозирует будущую просрочку. В таблице есть income из заявки, loan_amount из договора, credit_score на дату выдачи и collection_calls — звонки коллекторов после первой просрочки. Какой признак нельзя использовать при таком прогнозе?

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

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

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