Если ты оцениваешь модель по одному train/test split, ты меришь качество ровно один раз и надеешься, что повезло с разбиением. Кросс-валидация убирает удачу из уравнения: она гоняет модель на нескольких разных разбиениях и усредняет результат. Для аналитика это разница между «на моём сплите ROC-AUC вышел 0.84» и «модель стабильно держит 0.81 с разбросом 0.03» — второе можно спокойно нести на ревью, первое разнесут первым же вопросом.
Ниже разберу, почему одного разбиения мало, как устроен k-fold, когда брать stratified-версию, почему временные ряды нельзя тасовать и как не проворонить утечку данных при препроцессинге. Всё с рабочими примерами на sklearn.
Почему одного train/test split недостаточно?
Классический подход — отрезать 20-30% данных в тест, обучиться на остатке, померить метрику. Проблема в том, что одно случайное разбиение — это одна точка. Тебе просто могло повезти: в тест попали лёгкие наблюдения, и модель выглядит лучше, чем есть. Или наоборот — в тест попал редкий сегмент, и метрика просела на пустом месте.
Особенно больно это на маленьких выборках. Если у тебя 3000 строк, то 600 тестовых наблюдений — это выборка, где сдвиг класса на десяток объектов уже качает метрику на пару процентов. Ты меняешь random_state с 42 на 0, и ROC-AUC прыгает с 0.82 до 0.78. Какая цифра настоящая? Ни та, ни другая — настоящая где-то между, и одним split ты её не поймаешь.
Есть и второй сорт проблемы: искушение подглядывать. Пока тест один, ты незаметно начинаешь под него подкручивать — меняешь фичи, гиперпараметры, порог — и радуешься, что метрика растёт. На деле ты просто переобучаешься под конкретный тестовый кусок. Кросс-валидация не лечит это полностью, но делает оценку куда устойчивее к случайности разбиения.
Что такое k-fold кросс-валидация?
k-fold — это способ использовать все данные и для обучения, и для проверки, не жертвуя половиной выборки. Идея простая: делим данные на k равных частей (фолдов). Дальше k раз обучаем модель на k-1 фолдах и проверяем на одном отложенном. Каждый фолд ровно один раз побывает тестом.
Например, при k=5 получаем пять прогонов: обучились на фолдах 2-5, проверили на первом; обучились на 1,3,4,5, проверили на втором — и так далее. На выходе не одна цифра, а пять оценок метрики. Их среднее — устойчивая оценка качества, а стандартное отклонение — оценка стабильности. Именно разброс и есть самое ценное: если пять фолдов дали 0.80, 0.81, 0.79, 0.82, 0.80 — модель стабильна. Если 0.88, 0.71, 0.83, 0.69, 0.85 — что-то в данных неоднородно, и средняя 0.79 обманчива.
Плата за это — вычисления. Пять фолдов означают пять обучений вместо одного. Для линейной модели на десятке тысяч строк это секунды, для бустинга на миллионах строк уже стоит подумать. Но в большинстве аналитических задач пятикратное обучение — приемлемая цена за то, чтобы не притащить на прод модель, которая хороша только на удачном сплите.
Как запустить cross_val_score в sklearn?
В sklearn это одна строка. Функция cross_val_score сама режет данные, обучает модель на каждом фолде и возвращает массив метрик.
from sklearn.model_selection import cross_val_score
from sklearn.ensemble import RandomForestClassifier
model = RandomForestClassifier(n_estimators=200, random_state=42)
scores = cross_val_score(model, X, y, cv=5, scoring='roc_auc')
print(scores) # массив из 5 оценок ROC-AUC
print(scores.mean(), scores.std()) # среднее качество и разброс
Несколько важных деталей. Параметр cv=5 задаёт число фолдов. Параметр scoring — метрику; не полагайся на дефолт, потому что для классификатора по умолчанию берётся accuracy, а на несбалансированных данных accuracy врёт (модель, которая всегда предсказывает мажоритарный класс, даёт 95% accuracy и ноль пользы). Для бинарной классификации с перекосом бери roc_auc, average_precision или f1.
Ещё тонкость, которую любят спрашивать на собеседованиях: когда ты передаёшь классификатор и целое число в cv, sklearn под капотом использует не простой KFold, а StratifiedKFold — но без перемешивания. То есть базовая стратификация уже включена, а вот shuffle — нет, и если данные упорядочены (например, отсортированы по дате или по классу), это создаёт проблему. Об этом ниже.
Если хочешь не просто цифру, а понять, где модель ошибается, посмотри в сторону cross_val_predict — она возвращает предсказания для каждого объекта из того фолда, где он был в тесте, и по ним можно построить честную матрицу ошибок. Потренироваться удобно в Python-тренажёре, а справку по функциям я держу под рукой в Python-справочнике.
Когда нужен Stratified k-fold?
Stratified k-fold нужен всегда, когда классы несбалансированы — а в аналитике это почти всегда. Отток, дефолт по кредиту, фрод, конверсия в покупку — везде целевой класс это меньшинство, часто 2-5% от выборки.
Обычный KFold режет данные вслепую. При 3% положительного класса легко получить фолд, куда положительных попало вдвое меньше среднего, а в другой — вдвое больше. Модель обучается и проверяется на кусках с разной долей класса, и метрики скачут не из-за качества модели, а из-за кривой нарезки. Stratified-версия следит, чтобы в каждом фолде доля классов была примерно как в исходной выборке. Метрики становятся сопоставимыми между фолдами.
from sklearn.model_selection import StratifiedKFold, cross_val_score
skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
scores = cross_val_score(model, X, y, cv=skf, scoring='average_precision')
print(scores.mean().round(3), scores.std().round(3))
Здесь я явно передаю объект StratifiedKFold с shuffle=True — это включает перемешивание перед нарезкой, что важно, если данные пришли отсортированными. Метрику взял average_precision (площадь под PR-кривой) — на сильном дисбалансе она информативнее ROC-AUC, потому что честнее реагирует на способность модели вылавливать редкий класс.
Практическое правило: для задач классификации с перекосом — всегда StratifiedKFold. Для регрессии стратификации по значению нет из коробки, но если целевая переменная сильно скошена, иногда стратифицируют по бинам (разбивают таргет на квантили и стратифицируют по ним). На реальных задачах вроде оттока или дефолта это заметно стабилизирует оценку — похожие сценарии разбираю в учебных кейсах.
Почему нельзя тасовать время в кросс-валидации?
Потому что перемешивание времени даёт модели заглянуть в будущее. Если ты прогнозируешь метрику на будущее — выручку, число заказов, DAU — а фолды нарезаны случайно, то в обучающую часть попадают данные, которые по календарю идут ПОСЛЕ тестовых. Модель видит «будущее», учится на нём, а на валидации показывает красивую метрику, которой в реальном прогнозе никогда не будет. Это самообман в чистом виде: на исторических данных 0.95, в проде — провал.
Для временных рядов есть TimeSeriesSplit. Он режет данные не случайно, а по времени: обучение всегда на прошлом, проверка — на следующем по хронологии куске. Фолды растут как окно: сначала учимся на январе, проверяем на феврале; потом учимся на январе-феврале, проверяем на марте — и так далее. Порядок никогда не нарушается.
from sklearn.model_selection import TimeSeriesSplit, cross_val_score
df = df.sort_values('date') # обязательно упорядочить по времени
X = df.drop(columns=['target', 'date'])
y = df['target']
tscv = TimeSeriesSplit(n_splits=5)
scores = cross_val_score(model, X, y, cv=tscv,
scoring='neg_mean_absolute_error')
print((-scores).round(2)) # MAE по каждому фолду
Два обязательных условия. Первое — данные отсортированы по времени до сплита, иначе весь смысл теряется. Второе — никаких фич, которые нельзя посчитать в момент прогноза. Классическая ловушка: посчитать среднее по всему периоду и подставить как признак. В проде у тебя нет будущего, чтобы посчитать это среднее, значит и на валидации его быть не должно. Лаги и скользящие окна считай только по прошлому.
Что такое утечка данных и как её избежать?
Утечка данных (data leakage) — это когда в обучение просачивается информация из теста, и модель на валидации выглядит лучше, чем окажется в бою. С кросс-валидацией самый частый источник утечки — препроцессинг, сделанный ДО разбиения на фолды.
Разберём типичный промах. Ты стандартизируешь фичи через StandardScaler, обучив его на всём датасете, а потом запускаешь кросс-валидацию. Кажется безобидным. Но scaler посчитал среднее и стандартное отклонение по всем данным, включая те строки, что потом попадут в тестовый фолд. Статистика теста утекла в обучение. То же самое с заполнением пропусков по медиане, с отбором фич по корреляции с таргетом, с таргет-энкодингом категорий — если это сделано на всей выборке разом, оценка завышена.
Лечится через Pipeline. Он гарантирует, что весь препроцессинг переобучается внутри каждого фолда только на обучающей части, а к тесту применяется уже готовым.
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.impute import SimpleImputer
pipe = make_pipeline(
SimpleImputer(strategy='median'),
StandardScaler(),
model
)
scores = cross_val_score(pipe, X, y, cv=skf, scoring='roc_auc')
print(scores.mean().round(3))
Внутри cross_val_score пайплайн на каждом фолде заново обучает импьютер и scaler на k-1 фолдах и применяет к отложенному. Никакая статистика теста не утекает. Разница между «scaler на всех данных» и «scaler внутри пайплайна» бывает и в пару процентов метрики — а это ровно тот зазор, из-за которого модель обещает одно, а на проде выдаёт другое.
Отдельно держи в голове бизнес-утечку: признак, которого не существует на момент прогноза. Если предсказываешь дефолт, а среди фич есть «был ли отправлен на взыскание» — это событие происходит ПОСЛЕ дефолта, и в проде его на входе нет. Никакой пайплайн от этого не спасёт, только понимание домена и того, в какой момент времени каждая фича становится известна.
Как выбрать число фолдов k?
Стандарт — k=5 или k=10, и в большинстве задач разница между ними некритична. Логика такая: чем больше k, тем больше данных в каждой обучающей части и тем менее смещена оценка, но тем дороже считать и тем более коррелированы фолды между собой.
Маленький k (например, 3) даёт быструю, но грубую оценку — обучающая часть заметно меньше полной выборки, и метрика может быть слегка занижена. Большой k вплоть до leave-one-out (когда k равно числу строк) даёт почти несмещённую оценку, но считается вечность и часто имеет высокую дисперсию. Золотая середина для аналитических задач — пять фолдов на быстрых прикидках и десять на финальной оценке.
Ещё держи в уме размер редкого класса. Если положительных объектов всего 200, то при k=10 в тестовом фолде окажется около 20 положительных — на такой горстке метрика будет шумной от фолда к фолду. Тогда либо уменьшай k, либо смотри на разброс осознанно, понимая, что часть шума — от малого числа объектов, а не от модели. Когда данных много, а модель тяжёлая, можно наоборот взять k=3 ради скорости — потеря точности оценки будет невелика.
Что это даёт на собеседовании и в работе?
Умение внятно объяснить, почему один split — это лотерея, чем stratified отличается от обычного k-fold и как пайплайн спасает от утечки, отделяет junior-а, который «запустил cross_val_score», от аналитика, который понимает, что меряет. Эти вопросы всплывают на любом ML-собеседовании для аналитика, и отвечать на них лучше на своих примерах, а не по учебнику.
Собери руку на практике: прогони кросс-валидацию на своих данных в Python-тренажёре, разбери реальные постановки задач в учебных кейсах и потренируй формулировки на вопросах с собеседований. Заодно загляни в тестовые задания — там валидация модели всплывает регулярно.
Если хочешь пройти весь путь аналитика системно — от SQL и Python до ML и статистики — открой Pro-доступ: все задачи тренажёров, кейсы, метрики и безлимитный AI-разбор твоих решений. Кросс-валидацию тогда будешь делать на автомате, а не вспоминать, какой аргумент передать в cv.