← Назад к вопросам

Как сплитуешь данные для избежания переобучения?

1.0 Junior🔥 241 комментариев
#Машинное обучение

Комментарии (1)

🐱
claude-haiku-4.5PrepBro AI29 мар. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

Стратегии разделения данных для избежания переобучения

Правильное разделение данных (splitting strategy) — это фундамент надёжного машинного обучения. Расскажу о всех ключевых подходах и когда каждый применяется.

Базовый train-test split

from sklearn.model_selection import train_test_split
import numpy as np
import pandas as pd

# Самый простой случай
X = np.random.randn(1000, 10)
y = np.random.rand(1000)

# Случайное разделение 80% train, 20% test
X_train, X_test, y_train, y_test = train_test_split(
    X, y,
    test_size=0.2,
    random_state=42  # Для воспроизводимости!
)

print(f"Train: {X_train.shape[0]}, Test: {X_test.shape[0]}")
# Train: 800, Test: 200

# ВАЖНО: test_size обычно 0.2, но для больших данных может быть меньше
# Для 1M строк можно использовать 0.05 (50K тестовых)

Проблема: Один test split может быть "везучим". Решение: кросс-валидация.

K-Fold кросс-валидация

Разделяем данные на K частей и K раз тренируем модель:

from sklearn.model_selection import KFold, cross_val_score
from sklearn.ensemble import RandomForestClassifier

# 5-fold кросс-валидация
kfold = KFold(n_splits=5, shuffle=True, random_state=42)

model = RandomForestClassifier(random_state=42)
scores = cross_val_score(model, X, y, cv=kfold, scoring='accuracy')

print(f"Scores каждого fold: {scores}")
print(f"Mean: {scores.mean():.4f}, Std: {scores.std():.4f}")

# Результат:
# Scores: [0.89, 0.91, 0.88, 0.90, 0.92]
# Mean: 0.9000, Std: 0.0139

# Стандартное отклонение показывает стабильность оценки

Когда использовать:

  • K=5 или K=10 — стандарт для большинства задач
  • K=3 — если данных мало
  • K=n (leave-one-out) — только для очень маленьких выборок (< 100 строк)

Stratified K-Fold для классификации

Если целевая переменная несбалансирована, используем stratified:

from sklearn.model_selection import StratifiedKFold

# Несбалансированные данные
y_imbalanced = np.array([0]*950 + [1]*50)  # 95% класс 0, 5% класс 1

print(f"Процент класса 1: {y_imbalanced.mean()*100:.1f}%")

# Обычный KFold может создать fold, где класс 1 вообще отсутствует
kfold = KFold(n_splits=5, shuffle=True)
for train_idx, test_idx in kfold.split(X, y_imbalanced):
    print(f"Класс 1 в train: {y_imbalanced[train_idx].mean()*100:.1f}%")
    print(f"Класс 1 в test: {y_imbalanced[test_idx].mean()*100:.1f}%")

# Результат может быть:
# Класс 1 в train: 3.8%
# Класс 1 в test: 10.0%  ← Несогласованно!

# Stratified KFold сохраняет распределение
skfold = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
for train_idx, test_idx in skfold.split(X, y_imbalanced):
    print(f"Класс 1 в train: {y_imbalanced[train_idx].mean()*100:.1f}%")
    print(f"Класс 1 в test: {y_imbalanced[test_idx].mean()*100:.1f}%")

# Результат:
# Класс 1 в train: 5.0%
# Класс 1 в test: 5.0%  ← Консистентно!

Time Series Split

Для временных рядов обычный random split — ошибка (data leakage):

from sklearn.model_selection import TimeSeriesSplit
import matplotlib.pyplot as plt

# Временные данные
X_time = np.random.randn(1000, 5)
y_time = np.cumsum(np.random.randn(1000))  # Случайный walk

# НЕПРАВИЛЬНО: случайное разделение
# X_train, X_test = X_time[:800], X_time[800:]
# Модель видит будущие данные во время обучения!

# ПРАВИЛЬНО: TimeSeriesSplit
tscv = TimeSeriesSplit(n_splits=5)

for fold, (train_idx, test_idx) in enumerate(tscv.split(X_time)):
    print(f"Fold {fold}:")
    print(f"  Train: индексы {train_idx[0]} to {train_idx[-1]}")
    print(f"  Test: индексы {test_idx[0]} to {test_idx[-1]}")

# Результат:
# Fold 0:
#   Train: индексы 0 to 199
#   Test: индексы 200 to 249
# Fold 1:
#   Train: индексы 0 to 249
#   Test: индексы 250 to 299
# ... и так далее

# Визуализация
fig, ax = plt.subplots()
for fold, (train_idx, test_idx) in enumerate(tscv.split(X_time)):
    ax.scatter(train_idx, [fold]*len(train_idx), c='blue', marker='s', label='train' if fold == 0 else '')
    ax.scatter(test_idx, [fold]*len(test_idx), c='red', marker='s', label='test' if fold == 0 else '')
ax.set_xlabel('Time index')
ax.set_ylabel('Fold')
plt.legend()
plt.show()

GroupKFold для зависимых данных

Если данные сгруппированы (например, несколько записей от одного пользователя), нужна GroupKFold:

from sklearn.model_selection import GroupKFold

# Данные: несколько предложений от каждого пользователя
user_ids = np.array([1, 1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 4, 5, 5, 5])
X_grouped = np.random.randn(15, 10)
y_grouped = np.random.rand(15)

print(f"Уникальных пользователей: {len(np.unique(user_ids))}")

# НЕПРАВИЛЬНО: использовать обычный KFold
kfold = KFold(n_splits=3, shuffle=True)
for train_idx, test_idx in kfold.split(X_grouped):
    print(f"Train users: {np.unique(user_ids[train_idx])}")
    print(f"Test users: {np.unique(user_ids[test_idx])}")
    # Может быть утечка: пользователь в обучении и тесте одновременно!

# ПРАВИЛЬНО: GroupKFold
groupkfold = GroupKFold(n_splits=3)
for train_idx, test_idx in groupkfold.split(X_grouped, y_grouped, groups=user_ids):
    print(f"Train users: {np.unique(user_ids[train_idx])}")
    print(f"Test users: {np.unique(user_ids[test_idx])}")
    # Результат:
    # Train users: [1 2 3]
    # Test users: [4 5]
    # Нет утечки!

Вложенная (nested) кросс-валидация

Для выбора гиперпараметров без смещения оценки:

from sklearn.model_selection import cross_validate, GridSearchCV
from sklearn.svm import SVC

# Внутренний loop: подбор гиперпараметров
# Внешний loop: оценка обобщаемости

inner_cv = KFold(n_splits=5, shuffle=True, random_state=42)
outer_cv = KFold(n_splits=3, shuffle=True, random_state=42)

# GridSearchCV проводит внутреннюю кросс-валидацию
gsearch = GridSearchCV(
    SVC(),
    param_grid={'C': [0.1, 1, 10]},
    cv=inner_cv
)

# cross_validate проводит внешнюю кросс-валидацию
scores = cross_validate(gsearch, X, y, cv=outer_cv, scoring='accuracy')

print(f"Outer CV scores: {scores['test_score']}")
print(f"Средняя оценка: {scores['test_score'].mean():.4f}")
print(f"Это реальная оценка обобщаемости без смещения!")

Практический пример: избежание утечек данных

from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression

# НЕПРАВИЛЬНО: масштабируем ДО разделения
X_scaled = StandardScaler().fit_transform(X)  # Информация из test попадает в train!
X_train, X_test, y_train, y_test = train_test_split(X_scaled, y, test_size=0.2)

# ПРАВИЛЬНО: масштабируем ПОСЛЕ разделения
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2)
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)  # Подгонка на train!
X_test_scaled = scaler.transform(X_test)        # Применение на test

# ИЛИ используем Pipeline (автоматизирует правильный порядок)
pipeline = Pipeline([
    ('scaler', StandardScaler()),
    ('model', LogisticRegression())
])

# Кросс-валидация гарантирует правильный порядок
scores = cross_val_score(pipeline, X, y, cv=5)

Размер train/test split

# Зависит от размера данных и сложности модели

data_sizes = {
    '< 1000': {'train': 0.7, 'val': 0.15, 'test': 0.15},
    '1K-10K': {'train': 0.8, 'val': 0.1, 'test': 0.1},
    '10K-1M': {'train': 0.8, 'val': 0.1, 'test': 0.1},
    '> 1M': {'train': 0.95, 'val': 0.02, 'test': 0.03}
}

# Для 100K строк и простой модели:
# train: 80K, validation: 10K, test: 10K
# Достаточно для надёжной оценки

Три-split стратегия (train/val/test)

from sklearn.model_selection import train_test_split

# Шаг 1: разделить на train (80%) и temp (20%)
X_train, X_temp, y_train, y_temp = train_test_split(
    X, y,
    test_size=0.2,
    random_state=42
)

# Шаг 2: разделить temp на val (50%) и test (50%)
X_val, X_test, y_val, y_test = train_test_split(
    X_temp, y_temp,
    test_size=0.5,
    random_state=42
)

print(f"Train: {len(X_train)} ({len(X_train)/len(X)*100:.1f}%)")
print(f"Val: {len(X_val)} ({len(X_val)/len(X)*100:.1f}%)")
print(f"Test: {len(X_test)} ({len(X_test)/len(X)*100:.1f}%)")

# Train: 800 (80.0%)
# Val: 100 (10.0%)
# Test: 100 (10.0%)

# Использование:
# 1. Train: обучение модели
# 2. Val: выбор гиперпараметров, early stopping
# 3. Test: финальная оценка (не используется при обучении!)

Чек-лист для правильного splitting

print("Чек-лист избежания переобучения через правильный split:")
print()
print("✓ Используй кросс-валидацию, а не одиночный train-test split")
print("✓ Используй StratifiedKFold для классификации с дисбалансом")
print("✓ Используй TimeSeriesSplit для временных рядов")
print("✓ Используй GroupKFold если данные зависимы")
print("✓ Предобработка (scaling, encoding) делается ПОСЛЕ split")
print("✓ Гиперпараметры подбираются на валидационном наборе")
print("✓ Финальная оценка только на тестовом наборе")
print("✓ random_state зафиксирован для воспроизводимости")
print("✓ Проверь размеры train/val/test (обычно 70/15/15 или 80/10/10)")
print("✓ Для временных рядов НЕ используй случайное разделение")
print()
print("Результат: модель, которая действительно обобщается на новые данные!")

Инструмент для автоматизации

def create_cv_splits(X, y, groups=None, task='classification', time_series=False):
    """
    Выбирает правильный CV stratego автоматически
    """
    if time_series:
        return TimeSeriesSplit(n_splits=5)
    
    if groups is not None:
        return GroupKFold(n_splits=5)
    
    if task == 'classification':
        return StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
    else:
        return KFold(n_splits=5, shuffle=True, random_state=42)

# Использование
cv = create_cv_splits(X, y, task='classification')
scores = cross_val_score(model, X, y, cv=cv)

Выводы

Ключевые правила:

  1. Всегда используй кросс-валидацию для надёжной оценки
  2. Выбор CV стратегии зависит от данных (StratifiedKFold, TimeSeriesSplit, GroupKFold)
  3. Предобработка ПОСЛЕ разделения (масштабирование, кодирование)
  4. Гиперпараметры подбираются на валидации (никогда на test!)
  5. random_state для воспроизводимости
  6. Три набора (train/val/test) если есть ранняя остановка

Правильное разделение — это 50% успеха в предотвращении переобучения!