Привет, Хабр! Сегодня рассмотрим невинный на первый взгляд параметр shuffle=True
в train_test_split
.
Под «перемешать» подразумевается применение псевдо-рандомного пермутационного алгоритма (обычно Fisher–Yates) к индексам выборки до того, как мы режем её на train/test. Цель — заставить train-и-test быть независимыми и одинаково распределёнными (i.i.d.). В scikit-learn эта логика зашита в параметр shuffle
почти всех сплиттеров. В train_test_split
он True
по умолчанию, что прямо сказано в документации — «shuffle bool, default=True
».
train_test_split
from sklearn.model_selection import train_test_split
X_train, X_test, y_train, y_test = train_test_split(
features,
target,
test_size=0.2,
random_state=42, # надо для репликабельности
shuffle=True # смело тасуем
)
Когда shuffle=True
, функция:
Генерирует случайную перестановку индексов (учитывая
random_state
).По ней делит данные.
Если присвоить shuffle=False
, она просто берет “с головы” train_size
строк, а “хвост” — test. Расклад очевиден из исходников и подтверждён в официальном доке.
Когда shuffle=False — обязательное условие
Временные ряды
Time-series — классика, где порядок — закон. Если мы перемешаем, то модель увидит будущее раньше прошлого и станет гадалкой. С точки зрения статистики это “look-ahead bias”. На том же Cross-Validated прямым текстом: в time-series нужно держать хронологию и юзать TimeSeriesSplit
.
Зависимости внутри групп
Клинические данные, где несколько записей на одного пациента; логи пользователей, где одна сессия раскидана на десятки строк. Если рандомно раскидать строки по split’ам, то в test попадут “следы” тех же юзеров, что и в train, а это утечка через id-коррелированные признаки.
Продуктовые AB-эксперименты и всё, где важен session-level split
Тут групповая целостность — must-have. Мы шейкаем между группами, но не внутри.
Где случайность вредит обучению
Look-ahead bias — когда модель учится на будущей информации.
Target leakage — признак сформирован на основе целевой переменной или будущих значений.
Temporal leakage — метки пакуются по календарю: например,
is_holiday
. Если их перетасовать, тест узнает праздники раньше времени.
leakage — в целом сам по себе самый популярный баг ML-систем. Утечка часто выглядит невинно: добавили total_sales_next_month
как фичу для модели, предсказывающей спрос — и получили 99 % R².
Как делать GroupShuffle или TimeSeriesSplit
GroupShuffleSplit
from sklearn.model_selection import GroupShuffleSplit
gss = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42)
train_idx, test_idx = next(gss.split(X, y, groups=user_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]
GroupShuffleSplit
гарантирует, что у каждого user_id
ровно один сплит: либо train, либо test. Под капотом он рандомно тасует сами группы, а не записи.
TimeSeriesSplit
from sklearn.model_selection import TimeSeriesSplit
tscv = TimeSeriesSplit(n_splits=5, test_size=24*7) # неделя в часах
for fold, (train_idx, test_idx) in enumerate(tscv.split(X)):
model.fit(X.iloc[train_idx], y.iloc[train_idx])
y_pred = model.predict(X.iloc[test_idx])
...
Сплиттер отдаёт растающий train и скользящее окно test. Шейка нет вовсе — порядок священен.
Кейс на примере магазина котиков
У нас зоомаркет Purrfect Shop. В базе лежат четыре ключевых таблицы:
Таблица |
Что внутри |
Гранулярность |
---|---|---|
|
|
1 строка на владельца |
|
|
1 строка на котика |
|
|
1 строка на чек |
|
|
1 строка на позицию в чеке |
Мы хотим решить две задачи:
Churn-классификация: предсказать, уйдёт ли клиент в течение 30 дней.
Прогноз оборота на следующие 7 дней из тайм-серии.
Плюс обучаем CNN, которая по фото угадывает породу для автозаполнения карточек.
Churn-модель: где shuffle обязателен и где нельзя
Наивный, но опасный подход:
from sklearn.model_selection import train_test_split
X = features_df # собрали признаки на уровне *клиента*
y = labels_df['will_churn']
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.25, shuffle=True, random_state=42
)
Каждая строка — клиент, а значит зависимостей во времени нет. Shuffle здесь уместен: классы «уйдет/останется» раскиданы равномерно — модель не видит паттерна “первые 75 % клиентов — новые, последние — старые”.
Мы решаем “чуть улучшить” датасет и переходим на строки-чек. Один клиент = десятки чеков:
orders_df['will_churn'] = ...
X = orders_df.drop('will_churn', axis=1)
y = orders_df['will_churn']
# те же 5 строк кода:
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.25, shuffle=True, random_state=42
)
Теперь половина чеков Петя-Котолюб попала в train, половина — в test.
Утечка: признаки, вроде «total_sum_last_3_orders», пересекаются. Результат — AUC = 0.97 на тесте, но в проде падаем до 0.68 и ловим.
Правильно: GroupShuffleSplit
from sklearn.model_selection import GroupShuffleSplit
gss = GroupShuffleSplit(test_size=0.25, n_splits=1, random_state=42)
train_idx, test_idx = next(gss.split(X, y, groups=orders_df['customer_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]
Теперь каждый клиент живёт только в одном сплите. AUC честно падает до 0.79 — зато в проде всё стабильно.
Прогноз оборота: shuffle=False, иначе бабах
Как ломается time series:
from sklearn.model_selection import train_test_split
# aggregated_df: daily revenue, lag-features, holidays, etc.
X = aggregated_df.drop('revenue', axis=1)
y = aggregated_df['revenue']
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, shuffle=True, random_state=42
)
Модель случайно видит 2025-06-01 в train, а 2025-05-20 в test.
Используем TimeSeriesSplit:
from sklearn.model_selection import TimeSeriesSplit
import lightgbm as lgb
import numpy as np
tscv = TimeSeriesSplit(n_splits=5, test_size=7)
scores = []
for fold, (tr, val) in enumerate(tscv.split(X)):
model = lgb.LGBMRegressor(n_estimators=500, learning_rate=0.03)
model.fit(X.iloc[tr], y.iloc[tr])
preds = model.predict(X.iloc[val])
rmse = np.sqrt(((preds - y.iloc[val])**2).mean())
scores.append(rmse)
print(f'Fold {fold}: RMSE={rmse:.2f}')
print(f'Mean CV RMSE: {np.mean(scores):.2f}')
Без shuffle, с растущим окном.
CNN для фото-котиков
Когда обучаем сверточку, порядок картинок не важен; наоборот, shuffle помогает стохастическому градиенту быстрее и стабильнее сходиться.
from torchvision import datasets, transforms
from torch.utils.data import DataLoader
train_ds = datasets.ImageFolder(
root='cats/train',
transform=transforms.Compose([
transforms.Resize((224, 224)),
transforms.RandomHorizontalFlip(),
transforms.ToTensor()
])
)
train_loader = DataLoader(
train_ds,
batch_size=32,
shuffle=True, # критично!
num_workers=4,
pin_memory=True
)
val_loader = DataLoader(
val_ds,
batch_size=32,
shuffle=False, # чтоб метрики не «плясали»
num_workers=4
)
На примере нашего котомаркета видим три сценария:
Табличные i.i.d. данные — мешаем, чтобы избежать систематической ошибки.
Группы / время — бережём порядок, потому что утечка дороже.
Vision / NLP — мешаем внутри эпохи, но держим валидацию детерминированной.
Если сомневаетесь — не шейкайте.
Шейкать или не шейкать
Тип данных / задача |
Шейкать? |
Сплиттер |
Комментарий |
---|---|---|---|
Классический табличный ML |
Да |
|
Базовая практика, избегаем order-bias. |
Изображения/тексты без явных групп |
Да |
|
Сохраняем баланс классов. |
Логи пользователей, несколько строк на ID |
Нет |
|
Целостность группы важнее. |
Временные ряды (прогноз спроса, финансы) |
Нет |
|
Хронология важна. |
Подготовка hold-out для AB-теста |
Нет |
|
Сессии не должны пересекаться. |
Kaggle с фейк-ID, но явной утечки нет |
Скорее да |
|
Читайте описание соревнования. |
Tiny-датасет ≤ 100 строк |
Да, но фиксируйте seed |
Любой |
Варьируйте до 10-кратного CV для стабильности. |
Вывод
Если чувствуете запах временной или групповой зависимости — уберите руку от shuffle=True
и достаньте правильный сплиттер.
Готовите данные для моделей машинного обучения? Тогда знаете: неправильный сплит — и модель учит будущее, «подглядывает» в тест и в итоге проваливается в проде.
Если вам близки такие темы, как предотвращение утечек, GroupShuffle, TimeSeriesSplit, честные A/B‑тесты и грамотная работа с временными рядами — в Otus пройдут скоро открытые уроки, которые рекомендуем посетить:
3 июля в 18:00 — «Как правильно готовить данные для ML‑моделей?»
Разберётесь, какие ошибки подготовки мешают вашим моделям быть стабильными — и как этого избежать.16 июля в 18:00 — «Random Forest — мощный метод ансамблирования в ML»
На примерах разберётесь, почему даже самые сильные алгоритмы не спасут, если в train утекает test.
Хотите больше? Загляните в каталог курсов — там есть всё: от ML‑специализации до продвинутого Python.
А чтобы ничего не пропустить, добавьте календарь открытых уроков — пусть он напомнит вам, когда стоит подключиться к трансляции.
fishan
Хорошая статья для начинающих, годная. Начинал так же. сейчас не шейкаю вообще ничего встроенными инструментами. К подготовке обучающих данных стал подходить скрупулезно, споткнулся несколько раз, теперь это отдельное направление. Стратифицирую все ручками, сначала исследуем подопечного, потом разделяю скриптами написанными специально для данных с полным логом, потом еще проверка уже подготовленных данных. К слову датасеты разделяю на файлы обучения, валидации и тестовые, к тестовым данным подпускаю только изолированную модель, что бы сравнивать с валидацией, через несколько эпох, для контроля заучивания теста и валидации. При создании датасетов использую практически всегда RobustScaler и делаю клипинг квентилями. Данные сортирую по выборкам с пристальным вниманием, что бы во все выборки попали одинаково наборы данных, исключительные моменты, принудительно отправляются в обучающую выборку. Разделение данных во время обучения как по мне плохая практика, нет контроля качества данных. Временные ряды так же можно разделять на логические фрагменты. например, разделение на недели часто подходит.