Продвинутые способы кросс-валидации, разделения выборок рядов: Expanding Window Splitter, Blocked Time Series Split и другие

Традиционно в задачах прогнозирования для разделения выборок временных рядов используется метод TimeSeriesSplit из библиотеки scikit-learn. Этот подход гарантирует сохранение хронологической последовательности: обучающая выборка всегда предшествует тестовой, исключая утечку информации из будущего.

TimeSeriesSplit создает несколько последовательных сплитов с постепенно расширяющимся окном обучения, что позволяет надежно оценить обобщающую способность модели на будущих данных и является стандартом в большинстве прикладных задач машинного обучения.

Однако в ряде случаев, особенно при работе со сложными финансовыми рядами, высокочастотными данными или в условиях выраженной автокорреляции и структурных сдвигов, стандартного TimeSeriesSplit оказывается недостаточно для построения устойчивой прогнозной модели с высоким качеством.

Возникает потребность в более тонких стратегиях валидации, которые учитывают перекрытие информации, блокирование временных сегментов или очищение от пересекающихся наблюдений. Именно поэтому специалисты прибегают к нестандартным методам — ExpandingWindowSplitter, Purged Cross-Validation, Blocked Time Series Split и другим, о которых мы подробно расскажем в этой статье.

Expanding Window Splitter

В отличие от TimeSeriesSplit, где размер обучающего окна на каждом фолде остается фиксированным или растет по строгой схеме с непересекающимися тестовыми блоками, ExpandingWindowSplitter использует постепенно расширяющееся обучающее окно с фиксированным или настраиваемым шагом приращения. Тестовые сегменты тут так же следуют непосредственно за текущим обучающим окном, но их размер и частота могут варьироваться в зависимости от настроек.

Такое разбиение особенно эффективно для моделей, зависящих от накопленной статистики — например, моделей волатильности (GARCH), оценки спредов, факторных моделей риска или любых систем, где качество прогноза улучшается с увеличением объема исторических данных. Обучающая выборка растет по мере продвижения во времени, что точно отражает реальную практику: чем больше данных доступно на момент принятия решения, тем устойчивее и точнее становятся оценки параметров модели.

Принцип работы

Пусть временной ряд имеет длину N. Первое разбиение использует первые T точек для обучения и следующие H для теста. Далее длина обучающего сегмента увеличивается, тестовый сегмент сдвигается вперед, и цикл повторяется:

  1. Сначала: Обучающее окно: [0 : T], Тестовое окно: [T : T+H];
  2. Далее: Обучающее окно: [0 : T+H], Тестовое окно: [T+H : T+2H].

Этот подход сохраняет историческую непрерывность и избегает внезапных изменений «репрезентативности» обучающей выборки.

Пример кода, демонстрирующий особенности метода на временных рядах казначейских облигаций США:

import yfinance as yf
import pandas as pd
import matplotlib.pyplot as plt
import numpy as np
from sklearn.model_selection import TimeSeriesSplit

# Загрузка данных
data = yf.download("^FVX", start="2015-01-01", end="2025-10-31")
ts = data["Close"].dropna()
dates = ts.index

# Функция ExpandingWindowSplitter
def expanding_window_split(series, initial_train, step_size, test_size, gap=0):
    splits = []
    train_end = initial_train
    while train_end + test_size + gap <= len(series):
        train_start = 0
        test_start = train_end + gap
        test_end = test_start + test_size
        splits.append({
            'train': (train_start, train_end),
            'test': (test_start, test_end)
        })
        train_end += step_size  # ключевой параметр
    return splits

# Параметры
initial_train = 400
test_size = 100

# Вариант A: TimeSeriesSplit (step == test_size)
tscv = TimeSeriesSplit(n_splits=5, test_size=test_size)
tscv_splits = [(np.arange(0, i*(initial_train//5) + initial_train), 
                np.arange(i*(initial_train//5) + initial_train, i*(initial_train//5) + initial_train + test_size))
               for i in range(1, 6)]

# Вариант B: Expanding + перекрытие (step < test_size) 
overlap_splits = expanding_window_split(ts, initial_train, step_size=75, test_size=test_size) 

# Вариант C: Expanding + пропуск (step > test_size + gap)
gap_splits = expanding_window_split(ts, initial_train, step_size=90, test_size=test_size, gap=20)

# Визуализация
fig, axes = plt.subplots(3, 1, figsize=(14, 10), sharex=True)

strategies = [
    ("TimeSeriesSplit (scikit-learn)", tscv_splits[:5], "Фиксированный шаг = test_size"),
    ("ExpandingWindow + Перекрытие", overlap_splits[:5], "step=75, test=100 → перекрытие"),
    ("ExpandingWindow + Пропуск", gap_splits[:5], "step=90, test=100, gap=20 → пропуск")
]

for ax, (title, splits, subtitle) in zip(axes, strategies):
    for i, split in enumerate(splits):
        if isinstance(split, dict):
            train_slice = slice(split['train'][0], split['train'][1])
            test_slice = slice(split['test'][0], split['test'][1])
        else:
            train_slice = slice(split[0][0], split[0][-1]+1)
            test_slice = slice(split[1][0], split[1][-1]+1)

        train_dates = dates[train_slice]
        test_dates = dates[test_slice]

        # Train
        ax.fill_between(train_dates, i, i + 0.8, color='skyblue', edgecolor='navy', alpha=0.8)
        # Test
        ax.fill_between(test_dates, i, i + 0.8, color='lightcoral', edgecolor='crimson', alpha=0.9)

        # Подписи только на первом и последнем
        if i == 0:
            ax.text(dates[train_slice.stop-1], i + 0.4, f"Train → {dates[train_slice.stop-1].date()}",
                    va='center', ha='right', fontsize=8, color='navy')
            ax.text(dates[test_slice.start], i + 0.4, f"Test", va='center', ha='left', fontsize=8, color='crimson')
        if i == len(splits) - 1:
            ax.text(dates[train_slice.stop-1], i + 0.4, f"→ {dates[train_slice.stop-1].date()}",
                    va='center', ha='right', fontsize=8, color='navy')

    ax.set_yticks(range(len(splits)), [f"Фолд {j+1}" for j in range(len(splits))])
    ax.set_title(f"{title}\n{subtitle}", fontsize=12, pad=10)
    ax.grid(True, axis='x', alpha=0.3)

axes[0].legend(['Train', 'Test'], loc="upper left", bbox_to_anchor=(0, 1))
axes[-1].set_xlabel("Дата")
plt.tight_layout()
plt.subplots_adjust(top=0.92)
plt.show()

Сравнение стратегий кросс-валидации для временных рядов. Верхний график: TimeSeriesSplit (scikit-learn) с фиксированным шагом. 2 графика ниже: ExpandingWindowSplitter с перекрытием (step < test_size) и с пропуском данных (gap). Гибкие стратегии позволяют моделировать реальные сценарии бэктестинга, недоступные в стандартном TimeSeriesSplit

Рис. 1: Сравнение стратегий кросс-валидации для временных рядов.
Верхний график: TimeSeriesSplit (scikit-learn) с фиксированным шагом. 2 графика ниже: ExpandingWindowSplitter с перекрытием (step < test_size) и с пропуском данных (gap). Гибкие стратегии позволяют моделировать реальные сценарии бэктестинга, недоступные в стандартном TimeSeriesSplit

Преимущества и недостатки метода

Плюсы:

  1. Накопление знаний — модель использует всю доступную историю, качество растет с увеличением данных;
  2. Реалистичность — точно отражает real-world практику: в продакшене у вас всегда есть вся история до текущего момента;
  3. Стабильность оценок — больше данных = более устойчивые параметры модели (особенно для GARCH, факторных моделей);
  4. Непрерывность — нет резких скачков в составе обучающей выборки, плавное развитие модели;
  5. Честная независимая оценка на каждом фолде.
👉🏻  Прогнозирование временных рядов с помощью N-HITS, N-BEATS

Минусы:

  1. Растущая сложность — каждый следующий фолд обучается дольше (больше данных = больше времени);
  2. Неактуальность данных в train — модель «помнит» устаревшие паттерны, которые могут уже не работать (плохо при concept drift);
  3. Дисбаланс фолдов — последние фолды имеют намного больше train данных, чем первые (неравномерность оценок).

Обычно метод Expanding Window Splitter используют в кредитном скоринге, математическом моделирования, построении сложных моделей волатильности. В общем везде, где чем больше истории, тем лучше прогноз и важна статистическая устойчивость.

Purged & Embargoed Cross-Validation

В задачах, где метки или сигналы пересекаются по времени, может возникать перекрытие событий между обучающей и тестовой выборками. Это приводит к утечке информации. Метод Purged & Embargoed CV удаляет пересекающиеся ряды (purging) и вводит временной «запрет» (embargo) между фолдами.

Этот подход особенно важен при моделировании торговых сигналов, построенных на переходах между состояниями или длительных эффектах (holding period). Если не исключить пересечения выборок, то оценка эффективности модели будет завышена, что чревато неприятными сюрпризами в продакшене.

Пример кода и визуализации:

import yfinance as yf
import pandas as pd
import matplotlib.pyplot as plt
import numpy as np
from sklearn.model_selection import TimeSeriesSplit


def purged_embargoed_split(n, n_splits, test_size, embargo_pct=0.02, purge_pct=0.04):
    """
    Purged & Embargoed Cross-Validation
    
    Параметры:
    - n: количество наблюдений
    - n_splits: количество фолдов
    - test_size: размер test выборки
    - embargo_pct: процент embargo после test
    - purge_pct: процент purge перед test
    """
    splits = []
    purge_size = int(test_size * purge_pct)
    embargo_size = int(test_size * embargo_pct)
    
    # Расчет шага между фолдами
    step = (n - test_size - purge_size - embargo_size) // n_splits
    
    for i in range(n_splits):
        test_start = step * (i + 1)
        test_end = test_start + test_size
        
        if test_end + embargo_size > n:
            break
        
        # Train: от начала до purge зоны
        train_indices = np.arange(0, test_start - purge_size)
        
        # Test
        test_indices = np.arange(test_start, test_end)
        
        splits.append((train_indices, test_indices))
    
    return splits


# Загрузка данных
data = yf.download("^FVX", start="2023-10-31", end="2025-10-31", progress=False)
ts = data["Close"].dropna()
dates = ts.index

# Параметры
n_splits = 5
test_size = 100

# 1. Обычный TimeSeriesSplit
tscv = TimeSeriesSplit(n_splits=n_splits, test_size=test_size)
tscv_splits = list(tscv.split(ts))

# 2. Purged & Embargoed
purged_splits = purged_embargoed_split(len(ts), n_splits, test_size, 
                                       embargo_pct=0.02, purge_pct=0.04)

# Визуализация
fig, axes = plt.subplots(2, 1, figsize=(14, 8), sharex=True)

# График 1: TimeSeriesSplit
ax = axes[0]
for i, (train, test) in enumerate(tscv_splits):
    ax.fill_between(dates[train], i, i + 0.8, 
                   color='skyblue', edgecolor='navy', alpha=0.7)
    ax.fill_between(dates[test], i, i + 0.8, 
                   color='lightcoral', edgecolor='crimson', alpha=0.9)

ax.set_yticks(range(n_splits), [f"Фолд {j+1}" for j in range(n_splits)])
ax.set_title("A) TimeSeriesSplit (стандартный)\nБЕЗ purge/embargo", 
             fontsize=12, fontweight='bold')
ax.grid(True, axis='x', alpha=0.3)

# График 2: Purged & Embargoed
ax = axes[1]
for i, (train, test) in enumerate(purged_splits):
    # Train
    ax.fill_between(dates[train], i, i + 0.8, 
                   color='skyblue', edgecolor='navy', alpha=0.7)
    
    # Purge зона (между train и test)
    purge_start = train[-1] + 1
    purge_end = test[0]
    if purge_end > purge_start:
        ax.fill_between(dates[purge_start:purge_end], i, i + 0.8, 
                       color='orange', edgecolor='darkorange', alpha=0.6)
    
    # Test
    ax.fill_between(dates[test], i, i + 0.8, 
                   color='lightcoral', edgecolor='crimson', alpha=0.9)
    
    # Embargo зона (после test)
    embargo_size = int(test_size * 0.01)
    embargo_start = test[-1] + 1
    embargo_end = min(embargo_start + embargo_size, len(dates))
    if embargo_end > embargo_start:
        ax.fill_between(dates[embargo_start:embargo_end], i, i + 0.8, 
                       color='yellow', edgecolor='gold', alpha=0.6)

ax.set_yticks(range(len(purged_splits)), [f"Фолд {j+1}" for j in range(len(purged_splits))])
ax.set_title("B) Purged & Embargoed Cross-Validation \n" + 
             "С purge (4%) и embargo (2%)", 
             fontsize=12, fontweight='bold')
ax.grid(True, axis='x', alpha=0.3)
ax.set_xlabel("Дата", fontsize=11)

# Легенда
from matplotlib.patches import Patch
legend_elements = [
    Patch(facecolor='skyblue', edgecolor='navy', label='Train'),
    Patch(facecolor='lightcoral', edgecolor='crimson', label='Test'),
    Patch(facecolor='orange', edgecolor='darkorange', label='Purge'),
    Patch(facecolor='yellow', edgecolor='gold', label='Embargo')
]
axes[1].legend(handles=legend_elements, loc='upper left', fontsize=10)

plt.tight_layout()
plt.show()

Сравнение TimeSeriesSplit и Purged & Embargoed Cross-Validation. Стандартный подход не защищен от возможной утечки данных, в то время как Purged & Embargoed CV (B) использует защитные зоны purge и embargo для предотвращения перекрытия событий между train и test выборками

Рис. 2: Сравнение TimeSeriesSplit и Purged & Embargoed Cross-Validation. Стандартный подход не защищен от возможной утечки данных, в то время как Purged & Embargoed CV (B) использует защитные зоны purge и embargo для предотвращения перекрытия событий между train и test выборками

Преимущества и недостатки метода

Плюсы:

  1. Предотвращает все возможные варианты утечки данных — purge удаляет перекрывающиеся события между train и test (holding periods), а embargo создает временной буфер, что исключает влияние признаков с возможными значениями из будущего на модель;
  2. Честная оценка — дает реалистичную оценку модели без завышения метрик из-за пересечений.

Минусы:

  1. Меньше данных для train — purge и embargo удаляют значительную часть наблюдений (до 15%), из-за чего модель может хуже учиться;
  2. Сложность настройки — нужно правильно подобрать процент purge/embargo;
  3. Больше вычислений — дополнительная логика для вычисления перекрытий событий;
  4. Требует метаданные — нужны временные метки окончания событий (t1), которые не всегда доступны.
👉🏻  Прогнозирование трафика и конверсий сайта с помощью XGBoost

Blocked Time Series Split

Этот метод разрезает ряд на последовательные блоки одинакового размера. Блоки используются как фолды.

Ряд разбивается на K блоков:

Блоки: B1, B2, B3, …, BK

Для оценки используется комбинация блоков (например, обучение на B1+B2+B3 и тест на B4).

Метод Blocked Time Series Split снижает влияние автокорреляции, характерной для финансовых временных рядов. Когда важно оценить переносимость модели в условиях похожей структуры зависимости, разбиение на блоки позволяет избежать тесной связности соседних наблюдений.

Пример кода:

import yfinance as yf
import pandas as pd
import matplotlib.pyplot as plt
import numpy as np
from sklearn.model_selection import TimeSeriesSplit


def blocked_time_series_split(n, n_blocks):
    """
    Blocked Time Series Split
    
    Параметры:
    - n: количество наблюдений
    - n_blocks: количество блоков
    """
    block_size = n // n_blocks
    splits = []
    
    for i in range(1, n_blocks):
        # Train: все блоки до текущего
        train_end = i * block_size
        train_indices = np.arange(0, train_end)
        
        # Test: текущий блок
        test_start = train_end
        test_end = min(test_start + block_size, n)
        test_indices = np.arange(test_start, test_end)
        
        splits.append((train_indices, test_indices))
    
    return splits


# Загрузка данных
data = yf.download("^FVX", start="2019-01-01", end="2025-10-31", progress=False)
ts = data["Close"].dropna()
dates = ts.index

# Параметры
n_splits = 5
n_blocks = 6  # Для blocked split

# 1. Обычный TimeSeriesSplit
tscv = TimeSeriesSplit(n_splits=n_splits, test_size=100)
tscv_splits = list(tscv.split(ts))

# 2. Blocked Time Series Split
blocked_splits = blocked_time_series_split(len(ts), n_blocks)

# Визуализация
fig, axes = plt.subplots(2, 1, figsize=(14, 8), sharex=True)

# График 1: TimeSeriesSplit
ax = axes[0]
for i, (train, test) in enumerate(tscv_splits):
    ax.fill_between(dates[train], i, i + 0.8, 
                   color='skyblue', edgecolor='navy', alpha=0.7)
    ax.fill_between(dates[test], i, i + 0.8, 
                   color='lightcoral', edgecolor='crimson', alpha=0.9)

ax.set_yticks(range(n_splits), [f"Фолд {j+1}" for j in range(n_splits)])
ax.set_title("A) TimeSeriesSplit (стандартный)\nПеременный размер train, фиксированный test", 
             fontsize=12, fontweight='bold')
ax.grid(True, axis='x', alpha=0.3)

# График 2: Blocked Time Series Split
ax = axes[1]

# Сначала показываем все блоки
block_size = len(ts) // n_blocks
colors_palette = plt.cm.Set3(np.linspace(0, 1, n_blocks))

for block_idx in range(n_blocks):
    block_start = block_idx * block_size
    block_end = min(block_start + block_size, len(dates))
    
    # Рисуем тонкую полоску для всех блоков на фоне
    ax.fill_between(dates[block_start:block_end], -0.5, -0.2,
                   color=colors_palette[block_idx], edgecolor='black', 
                   alpha=0.5, linewidth=1)
    ax.text(dates[block_start + (block_end-block_start)//2], -0.35, 
           f'B{block_idx+1}', ha='center', va='center', fontsize=8, fontweight='bold')

# Теперь рисуем фолды
for i, (train, test) in enumerate(blocked_splits):
    ax.fill_between(dates[train], i, i + 0.8, 
                   color='skyblue', edgecolor='navy', alpha=0.7)
    ax.fill_between(dates[test], i, i + 0.8, 
                   color='lightcoral', edgecolor='crimson', alpha=0.9)

ax.set_yticks(range(len(blocked_splits)), [f"Фолд {j+1}" for j in range(len(blocked_splits))])
ax.set_title("B) Blocked Time Series Split\n" + 
             f"Данные разделены на {n_blocks} блоков равного размера", 
             fontsize=12, fontweight='bold')
ax.set_ylim(-0.6, len(blocked_splits))
ax.grid(True, axis='x', alpha=0.3)
ax.set_xlabel("Дата", fontsize=11)

# Легенда
from matplotlib.patches import Patch
legend_elements = [
    Patch(facecolor='skyblue', edgecolor='navy', label='Train'),
    Patch(facecolor='lightcoral', edgecolor='crimson', label='Test'),
    Patch(facecolor='gray', alpha=0.5, label='Блоки (B1...B6)')
]
axes[1].legend(handles=legend_elements, loc='upper left', fontsize=10)

plt.tight_layout()
plt.show()

# Статистика
print("=" * 80)
print("BLOCKED TIME SERIES SPLIT - АНАЛИЗ")
print("=" * 80)
print()
print(f"Размер блока: {block_size} наблюдений")
print(f"Количество блоков: {n_blocks}")
print()

for i, (train, test) in enumerate(blocked_splits):
    n_train_blocks = len(train) // block_size
    print(f"Фолд {i+1}: Train = B1...B{n_train_blocks} ({len(train)} наблюдений), "
          f"Test = B{n_train_blocks+1} ({len(test)} наблюдений)")

Сравнение TimeSeriesSplit и Blocked Time Series Split. Стандартный метод использует переменный размер train с фиксированным test, в то время как Blocked Split (B) делит временной ряд на равные блоки (B1-B6), что снижает влияние автокорреляции и создает четкие границы между train и test

Рис. 3: Сравнение TimeSeriesSplit и Blocked Time Series Split. Стандартный метод использует переменный размер train с фиксированным test, в то время как Blocked Split (B) делит временной ряд на равные блоки (B1-B6), что снижает влияние автокорреляции и создает четкие границы между train и test

Представленный выше график наглядно показывает разницу между методам разделения выборок. А пояснительная таблица позволяет оценить консистентность каждой выборки.

================================================================================
BLOCKED TIME SERIES SPLIT - АНАЛИЗ
================================================================================

Размер блока: 286 наблюдений
Количество блоков: 6

Фолд 1: Train = B1...B1 (286 наблюдений), Test = B2 (286 наблюдений)
Фолд 2: Train = B1...B2 (572 наблюдений), Test = B3 (286 наблюдений)
Фолд 3: Train = B1...B3 (858 наблюдений), Test = B4 (286 наблюдений)
Фолд 4: Train = B1...B4 (1144 наблюдений), Test = B5 (286 наблюдений)
Фолд 5: Train = B1...B5 (1430 наблюдений), Test = B6 (286 наблюдений)

Преимущества и недостатки метода

Плюсы:

  1. Снижение эффекта автокорреляций — блоки создают естественные границы, уменьшая влияние связности соседних наблюдений;
  2. Оптимально для рядов с выраженной сезонностью. Когда известно, что есть четкая сезонность (например, циклы внутри года), то можно определять размеры тестовых фолдов в определенный горизонт (год);
  3. Равномерное распределение — сбалансированные фолды легче анализировать и интерпретировать.

Минусы:

  1. Потеря временной близости — может пропустить краткосрочные паттерны на границах блоков;
  2. Жесткость — размер блока фиксирован, не адаптируется к изменениям волатильности или частоте данных;
  3. Граничные эффекты — разрыв между блоками может исключить важные переходные периоды;
  4. Меньше контекста — первые блоки обучаются на меньшем объеме истории, чем в Expanding Window или TimeSeriesSplit.

Метод Blocked Time Series Split обычно применяют в моделях с сильной автокорреляцией временных рядов, сильной сезонностью, а также в HFT (высокочастотном трейдинге).

👉🏻  Прогнозирование трафика и конверсий сайта с помощью Catboost

Nested Cross-Validation для временных рядов

Nested CV позволяет разделить оценку модели на два уровня:

  • Внешний (оценка качества);
  • Внутренний (подбор параметров).

Временная структура сохраняется в обоих уровнях. Такой подход дает более устойчивую оценку моделей, чувствительных к гиперпараметрам.

Принцип работы метода Nested CV следующий:

  1. Внешний цикл формирует обучающие и тестовые фолды по времени;
  2. Для каждого обучающего фолда запускается внутренний цикл разбиений (например, Expanding Window) для подбора гиперпараметров.

Пример схемы разбиения:

import yfinance as yf
import pandas as pd
import matplotlib.pyplot as plt
import numpy as np
from sklearn.model_selection import TimeSeriesSplit


def nested_cv_split(n, n_outer_splits=3, n_inner_splits=3):
    """
    Nested Cross-Validation для временных рядов
    
    Параметры:
    - n: количество наблюдений
    - n_outer_splits: количество внешних фолдов (оценка качества)
    - n_inner_splits: количество внутренних фолдов (подбор параметров)
    """
    outer_splits = []
    
    # Внешний цикл: делим данные на крупные фолды
    outer_tscv = TimeSeriesSplit(n_splits=n_outer_splits)
    
    for outer_idx, (outer_train, outer_test) in enumerate(outer_tscv.split(range(n))):
        # Внутренний цикл: подбор параметров на outer_train
        inner_splits = []
        inner_tscv = TimeSeriesSplit(n_splits=n_inner_splits)
        
        for inner_train, inner_val in inner_tscv.split(outer_train):
            # Преобразуем локальные индексы во глобальные
            global_inner_train = outer_train[inner_train]
            global_inner_val = outer_train[inner_val]
            inner_splits.append((global_inner_train, global_inner_val))
        
        outer_splits.append({
            'outer_train': outer_train,
            'outer_test': outer_test,
            'inner_splits': inner_splits
        })
    
    return outer_splits


def standard_cv_split(n, n_splits=3):
    """
    Стандартный TimeSeriesSplit (одноуровневый)
    """
    tscv = TimeSeriesSplit(n_splits=n_splits)
    return list(tscv.split(range(n)))


# Загрузка данных
data = yf.download("^FVX", start="2022-10-31", end="2025-10-31", progress=False)
ts = data["Close"].dropna()
dates = ts.index

# Параметры
n_outer = 3
n_inner = 3

# 1. Стандартный одноуровневый CV
standard_splits = standard_cv_split(len(ts), n_splits=n_outer)

# 2. Nested CV
nested_splits = nested_cv_split(len(ts), n_outer_splits=n_outer, n_inner_splits=n_inner)

# Визуализация
fig, axes = plt.subplots(2, 1, figsize=(14, 10), sharex=True)

# График 1: Стандартный CV
ax = axes[0]
for i, (train, test) in enumerate(standard_splits):
    ax.fill_between(dates[train], i, i + 0.8, 
                   color='skyblue', edgecolor='navy', alpha=0.7)
    ax.fill_between(dates[test], i, i + 0.8, 
                   color='lightcoral', edgecolor='crimson', alpha=0.9)

ax.set_yticks(range(len(standard_splits)), [f"Фолд {j+1}" for j in range(len(standard_splits))])
ax.set_title("A) Стандартный TimeSeriesSplit (одноуровневый)\n" + 
             "Подбор параметров и оценка качества на одних и тех же разбиениях", 
             fontsize=12, fontweight='bold')
ax.grid(True, axis='x', alpha=0.3)
ax.legend(['Train', 'Test'], loc='upper left', fontsize=10)

# График 2: Nested CV
ax = axes[1]

row_idx = 0
for outer_idx, outer_split in enumerate(nested_splits):
    outer_train = outer_split['outer_train']
    outer_test = outer_split['outer_test']
    inner_splits = outer_split['inner_splits']
    
    # Рисуем внешний test (финальная оценка)
    ax.fill_between(dates[outer_test], row_idx, row_idx + 0.8, 
                   color='lightcoral', edgecolor='crimson', alpha=0.9,
                   label='Outer Test (оценка)' if outer_idx == 0 else '')
    
    # Подпись для внешнего фолда
    ax.text(dates[0], row_idx + 0.4, f'Outer {outer_idx+1}', 
           fontsize=9, fontweight='bold', va='center')
    
    row_idx += 1
    
    # Рисуем внутренние разбиения (подбор параметров)
    for inner_idx, (inner_train, inner_val) in enumerate(inner_splits):
        # Inner train
        ax.fill_between(dates[inner_train], row_idx, row_idx + 0.6, 
                       color='lightblue', edgecolor='blue', alpha=0.6,
                       label='Inner Train' if outer_idx == 0 and inner_idx == 0 else '')
        
        # Inner validation
        ax.fill_between(dates[inner_val], row_idx, row_idx + 0.6, 
                       color='lightyellow', edgecolor='orange', alpha=0.8,
                       label='Inner Val (подбор)' if outer_idx == 0 and inner_idx == 0 else '')
        
        # Подпись для внутреннего фолда
        ax.text(dates[0], row_idx + 0.3, f'  Inner {inner_idx+1}', 
               fontsize=8, va='center', style='italic')
        
        row_idx += 1
    
    # Пустая строка между внешними фолдами
    row_idx += 0.3

ax.set_yticks([])
ax.set_title("B) Nested Cross-Validation (двухуровневый) \n" + 
             "Внешний цикл = оценка качества, Внутренний цикл = подбор гиперпараметров", 
             fontsize=12, fontweight='bold')
ax.set_ylim(-0.5, row_idx)
ax.grid(True, axis='x', alpha=0.3)
ax.set_xlabel("Дата", fontsize=11)

# Легенда
ax.legend(loc='upper left', fontsize=10, ncol=2)

plt.tight_layout()
plt.show()

# Статистика
print("=" * 80)
print("NESTED CROSS-VALIDATION - АНАЛИЗ")
print("=" * 80)
print()

for outer_idx, outer_split in enumerate(nested_splits):
    outer_train = outer_split['outer_train']
    outer_test = outer_split['outer_test']
    inner_splits = outer_split['inner_splits']
    
    print(f"OUTER FOLD {outer_idx+1}:")
    print(f"  Outer Train: {len(outer_train)} наблюдений")
    print(f"  Outer Test:  {len(outer_test)} наблюдений (финальная оценка)")
    print(f"  ")
    print(f"  Внутренние разбиения (подбор параметров на outer train):")
    
    for inner_idx, (inner_train, inner_val) in enumerate(inner_splits):
        print(f"    Inner Fold {inner_idx+1}:")
        print(f"      Train: {len(inner_train)} наблюдений")
        print(f"      Val:   {len(inner_val)} наблюдений")
    print()

Сравнение стандартного CV и Nested Cross-Validation. Одноуровневый подход (A) использует одни и те же разбиения для подбора параметров и оценки качества, и не пригоден для честного подбора гиперпараметров. Nested CV (B) разделяет эти задачи: внешний цикл оценивает качество на независимых данных, а внутренний цикл подбирает оптимальные гиперпараметры на обучающей выборке

Рис. 4: Сравнение стандартного CV и Nested Cross-Validation. Одноуровневый подход (A) использует одни и те же разбиения для подбора параметров и оценки качества, и не пригоден для честного подбора гиперпараметров. Nested CV (B) разделяет эти задачи: внешний цикл оценивает качество на независимых данных, а внутренний цикл подбирает оптимальные гиперпараметры на обучающей выборке

================================================================================
NESTED CROSS-VALIDATION - АНАЛИЗ
================================================================================

OUTER FOLD 1:
  Outer Train: 189 наблюдений
  Outer Test:  188 наблюдений (финальная оценка)
  
  Внутренние разбиения (подбор параметров на outer train):
    Inner Fold 1:
      Train: 48 наблюдений
      Val:   47 наблюдений
    Inner Fold 2:
      Train: 95 наблюдений
      Val:   47 наблюдений
    Inner Fold 3:
      Train: 142 наблюдений
      Val:   47 наблюдений

OUTER FOLD 2:
  Outer Train: 377 наблюдений
  Outer Test:  188 наблюдений (финальная оценка)
  
  Внутренние разбиения (подбор параметров на outer train):
    Inner Fold 1:
      Train: 95 наблюдений
      Val:   94 наблюдений
    Inner Fold 2:
      Train: 189 наблюдений
      Val:   94 наблюдений
    Inner Fold 3:
      Train: 283 наблюдений
      Val:   94 наблюдений

OUTER FOLD 3:
  Outer Train: 565 наблюдений
  Outer Test:  188 наблюдений (финальная оценка)
  
  Внутренние разбиения (подбор параметров на outer train):
    Inner Fold 1:
      Train: 142 наблюдений
      Val:   141 наблюдений
    Inner Fold 2:
      Train: 283 наблюдений
      Val:   141 наблюдений
    Inner Fold 3:
      Train: 424 наблюдений
      Val:   141 наблюдений

Таким образом, мы видим что обучение гиперпараметров происходит только внутри внешнего фолда, что снижает переобучение и делает ML-модель более устойчивой к будущим данным.

👉🏻  Оптимизация цепочек поставок с помощью машинного обучения

Преимущества метода Nested CV:

  1. Разделяет подбор параметров и оценку качества;
  2. Предотвращает переобучение при оптимизации гиперпараметров;
  3. Дает более честную оценку обобщающей способности модели;
  4. Сохраняет временную структуру данных на обоих уровнях.

Минус метода — значительно сокращение обучающей выборки, что может привести к тому, что модель при обучении не увидит достаточно данных для поиска закономерностей и различных паттернов.

Sliding Window with Overlapping Test Segments

Данный метод использует фиксированное окно обучения и перекрывающиеся тестовые сегменты. Подходит, когда важно измерять стабильность метрик во времени и быстро обновлять модель.

import yfinance as yf
import pandas as pd
import matplotlib.pyplot as plt
import numpy as np


def sliding_window_overlapping(n, window_size, test_size, step_size):
    """
    Sliding Window with Overlapping Test Segments

    Параметры:
    - n: количество наблюдений
    - window_size: фиксированный размер окна обучения
    - test_size: размер тестового сегмента
    - step_size: шаг сдвига окна (если < test_size, то перекрытие)
    """
    splits = []

    start = 0
    while start + window_size + test_size <= n:
        train_start = start
        train_end = start + window_size
        test_start = train_end
        test_end = test_start + test_size

        train_indices = np.arange(train_start, train_end)
        test_indices = np.arange(test_start, test_end)

        splits.append((train_indices, test_indices))

        start += step_size

    return splits


def expanding_window_non_overlapping(n, initial_train, test_size, n_splits):
    """
    Expanding Window с непересекающимися test сегментами
    """
    splits = []

    for i in range(n_splits):
        test_start = initial_train + i * test_size
        test_end = test_start + test_size

        if test_end > n:
            break

        # Train растет от начала до test
        train_indices = np.arange(0, test_start)
        test_indices = np.arange(test_start, test_end)

        splits.append((train_indices, test_indices))

    return splits


# Загрузка данных
data = yf.download("^FVX", start="2023-01-01", end="2025-10-31", progress=False)
ts = data["Close"].dropna()
dates = ts.index

# Параметры
window_size = 400
test_size = 100
step_size = 50  # < test_size → перекрытие test сегментов
initial_train = 400

# 1. Expanding Window
expanding_splits = expanding_window_non_overlapping(len(ts), initial_train=initial_train,
                                                     test_size=test_size, n_splits=5)

# 2. Sliding Window with Overlapping Test Segments
sliding_splits = sliding_window_overlapping(len(ts), window_size=window_size,
                                           test_size=test_size, step_size=step_size)

# Ограничиваем количество фолдов для визуализации
sliding_splits = sliding_splits[:10]

# Визуализация
fig, axes = plt.subplots(2, 1, figsize=(14, 10), sharex=True)

# График 1: Expanding Window
ax = axes[0]
for i, (train, test) in enumerate(expanding_splits):
    ax.fill_between(dates[train], i, i + 0.8,
                   color='skyblue', edgecolor='navy', alpha=0.7)
    ax.fill_between(dates[test], i, i + 0.8,
                   color='lightcoral', edgecolor='crimson', alpha=0.9)

ax.set_yticks(range(len(expanding_splits)), [f"Фолд {j+1}" for j in range(len(expanding_splits))])
ax.set_title("A) Expanding Window\n" +
             "Растущий train, test сегменты последовательные БЕЗ пересечений",
             fontsize=12, fontweight='bold')
ax.grid(True, axis='x', alpha=0.3)
ax.legend(['Train', 'Test'], loc='upper left', fontsize=10)

# График 2: Sliding Window with Overlapping
ax = axes[1]

# Визуализируем перекрытия test сегментов
for i, (train, test) in enumerate(sliding_splits):
    # Train window
    ax.fill_between(dates[train], i, i + 0.8,
                   color='skyblue', edgecolor='navy', alpha=0.7)

    # Test segment
    ax.fill_between(dates[test], i, i + 0.8,
                   color='lightcoral', edgecolor='crimson', alpha=0.9)

    # Показываем перекрытие с предыдущим test
    if i > 0:
        prev_test = sliding_splits[i-1][1]
        current_test = test

        # Находим пересечение
        overlap = np.intersect1d(prev_test, current_test)
        if len(overlap) > 0:
            # Подсвечиваем зону перекрытия
            ax.fill_between(dates[overlap], i-0.1, i + 0.9,
                          color='yellow', alpha=0.4, edgecolor='orange', linewidth=2,
                          label='Перекрытие test' if i == 1 else '')

ax.set_yticks(range(len(sliding_splits)), [f"Фолд {j+1}" for j in range(len(sliding_splits))])
ax.set_title("B) Sliding Window with Overlapping Test Segments \n" +
             f"Фиксированный train ({window_size}), step={step_size} < test={test_size} → test ПЕРЕСЕКАЮТСЯ",
             fontsize=12, fontweight='bold')
ax.grid(True, axis='x', alpha=0.3)
ax.set_xlabel("Дата", fontsize=11)

# Легенда
from matplotlib.patches import Patch
legend_elements = [
    Patch(facecolor='skyblue', edgecolor='navy', label='Train'),
    Patch(facecolor='lightcoral', edgecolor='crimson', label='Test'),
    Patch(facecolor='yellow', edgecolor='orange', alpha=0.4, label='Перекрытие test сегментов')
]
ax.legend(handles=legend_elements, loc='upper left', fontsize=10)

plt.tight_layout()
plt.show()

Сравнение методов Expanding Window и Sliding Window with Overlapping Test. Expanding Window (A) использует растущую обучающую выборку с последовательными непересекающимися test сегментами (каждый следующий test идет сразу после предыдущего). Sliding Window (B) применяет фиксированное окно обучения и сдвигает его с шагом меньше размера test (step=50 < test=100), создавая перекрытие между тестовыми сегментами (желтые зоны), что позволяет получить больше оценок модели во времени

Рис. 5: Сравнение методов Expanding Window и Sliding Window with Overlapping Test. Expanding Window (A) использует растущую обучающую выборку с последовательными непересекающимися test сегментами (каждый следующий test идет сразу после предыдущего). Sliding Window (B) применяет фиксированное окно обучения и сдвигает его с шагом меньше размера test (step=50 < test=100), создавая перекрытие между тестовыми сегментами (желтые зоны), что позволяет получить больше оценок модели во времени

Преимущества и недостатки метода

Достоинства метода Sliding Window with Overlapping Test следующие:

  1. Больше оценок - перекрытие дает в 2x больше измерений производительности без дополнительных данных;
  2. Мониторинг во времени - частые оценки сигнализируют о деградации модели раньше других методов валидации (rolling performance);
  3. Фиксированная сложность - постоянный размер train = предсказуемое время обучения и память;
  4. Адаптация к изменениям - модель забывает старые, уже не актуальные данные, подстраивается под текущие условия (concept drift);
  5. Быстрое обновление - идеально для продакшен систем с частым переобучением.

Минусы подхода: корреляция оценок (они не независимы) и возможная утечка данных из будущего (требуется дополнительный код на ее проверки).

Данный метод часто применяется в анализе временных рядов в областях прогнозирования спроса, детекции фрода, аномалий, алгоритмической торговле. В общем, везде, где нужен быстрый мониторинг и адаптация к изменениям.

Group Time Series Split

Метод Group Time Series Split применяется когда данные имеют естественную группировку (например, несколько активов, клиентов, регионов или продуктов), и важно гарантировать, что наблюдения из одной группы не попадают одновременно в обучающую и тестовую выборки. Это важно для предотвращения утечки информации через групповые паттерны.

Традиционный TimeSeriesSplit разделяет данные только по времени, игнорируя групповую структуру. Если у нас есть временные ряды для нескольких активов (например, акций, валютных пар, товаров), и мы просто делим по времени, то одна и та же акция может оказаться и в train, и в test на одном временном отрезке. Это приводит к переоценке качества модели из-за корреляций внутри группы.

Group Time Series Split решает эту проблему, разделяя группы между train и test, при этом сохраняя временную структуру внутри каждой группы. Существует два основных подхода:

  • Group-based split: Некоторые группы целиком идут в train, другие - в test (на каждом временном окне);
  • Time-then-group split: Сначала делим по времени, затем внутри каждого временного окна делим группы.

Второй подход более распространен в финансах, так как позволяет обучаться на исторических данных всех групп, но тестировать на будущем только части групп.

import pandas as pd
import matplotlib.pyplot as plt
import numpy as np
def generate_synthetic_multi_asset_data(n_companies=5, start_date='2023-01-01', n_days=700):
"""
Генерирует синтетические данные
"""
np.random.seed(42)
companies = [f'Company{i+1}' for i in range(n_companies)]
dates = pd.date_range(start=start_date, periods=n_days, freq='D')
data_list = []
for company in companies:
# Генерируем случайные цены с трендом
prices = 100 + np.cumsum(np.random.randn(n_days) * 2)
returns = np.diff(prices, prepend=prices[0]) / prices[0]
df = pd.DataFrame({
'Close': prices,
'returns': returns,
'company': company
}, index=dates)
data_list.append(df)
data = pd.concat(data_list)
data = data.sort_index()
return data, companies
def group_time_series_split(data, group_col, n_splits=5, test_group_size=0.4):
dates = data.index.unique()
unique_groups = data[group_col].unique()
n_groups = len(unique_groups)
n_test_groups = max(1, int(n_groups * test_group_size))
# Создаем временные разбиения
n_dates = len(dates)
test_size = n_dates // (n_splits + 1)
splits = []
# Создаем разные комбинации групп для каждого фолда
np.random.seed(42)
all_group_combinations = []
for i in range(n_splits):
# Для каждого фолда выбираем разные группы для test
test_groups = np.random.choice(unique_groups, n_test_groups, replace=False)
train_groups = np.setdiff1d(unique_groups, test_groups)
all_group_combinations.append((train_groups, test_groups))
for i in range(n_splits):
train_end_idx = test_size * (i + 1)
test_end_idx = min(test_size * (i + 2), n_dates)
train_dates = dates[:train_end_idx]
test_dates = dates[train_end_idx:test_end_idx]
if len(test_dates) == 0:
break
train_groups, test_groups = all_group_combinations[i]
train_mask = data.index.isin(train_dates) & data[group_col].isin(train_groups)
test_mask = data.index.isin(test_dates) & data[group_col].isin(test_groups)
train_indices = np.where(train_mask)[0]
test_indices = np.where(test_mask)[0]
if len(train_indices) > 0 and len(test_indices) > 0:
splits.append((train_indices, test_indices, train_groups, test_groups))
return splits
def standard_time_series_split_for_groups(data, n_splits=5):
"""
Стандартный TimeSeriesSplit
"""
dates = data.index.unique()
n_dates = len(dates)
test_size = n_dates // (n_splits + 1)
splits = []
for i in range(n_splits):
train_end_idx = test_size * (i + 1)
test_end_idx = min(test_size * (i + 2), n_dates)
train_dates = dates[:train_end_idx]
test_dates = dates[train_end_idx:test_end_idx]
if len(test_dates) == 0:
break
train_mask = data.index.isin(train_dates)
test_mask = data.index.isin(test_dates)
train_indices = np.where(train_mask)[0]
test_indices = np.where(test_mask)[0]
splits.append((train_indices, test_indices))
return splits
# Генерация данных
data, companies = generate_synthetic_multi_asset_data(n_companies=5, n_days=700)
# Создание разбиений
standard_splits = standard_time_series_split_for_groups(data, n_splits=5)
group_splits = group_time_series_split(data, 'company', n_splits=5, test_group_size=0.4)
# Визуализация
fig, axes = plt.subplots(2, 1, figsize=(16, 10))
colors = ['#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd']
# График 1: Стандартный TimeSeriesSplit
ax = axes[0]
for i, (train_idx, test_idx) in enumerate(standard_splits):
train_data = data.iloc[train_idx]
test_data = data.iloc[test_idx]
y_position = i * 1.5
for comp_idx, company in enumerate(companies):
# Train - горизонтальные линии
train_comp = train_data[train_data['company'] == company]
if len(train_comp) > 0:
ax.plot([train_comp.index.min(), train_comp.index.max()], 
[y_position + comp_idx * 0.25, y_position + comp_idx * 0.25],
color=colors[comp_idx], linewidth=4, alpha=0.6, solid_capstyle='butt')
# Test - горизонтальные линии
test_comp = test_data[test_data['company'] == company]
if len(test_comp) > 0:
ax.plot([test_comp.index.min(), test_comp.index.max()], 
[y_position + comp_idx * 0.25, y_position + comp_idx * 0.25],
color=colors[comp_idx], linewidth=6, alpha=0.95, solid_capstyle='butt')
# Легенда для компаний
legend_elements = [plt.Line2D([0], [0], color=colors[i], linewidth=4, 
label=companies[i], alpha=0.7) 
for i in range(len(companies))]
ax.legend(handles=legend_elements, loc='upper left', ncol=5, fontsize=10, 
framealpha=0.9, title='Компании')
ax.set_yticks([i * 1.5 + 0.5 for i in range(len(standard_splits))], 
[f"Фолд {j+1}" for j in range(len(standard_splits))])
ax.set_title("A) Стандартный TimeSeriesSplit\n" + 
"Все группы присутствуют и в train (тонкие линии), и в test (толстые линии)\n" +
"→ РИСК УТЕЧКИ через межгрупповые корреляции", 
fontsize=13, fontweight='bold', pad=15)
ax.grid(True, axis='x', alpha=0.3, linestyle='--')
ax.set_ylabel('', fontsize=11)
ax.set_ylim(-0.3, len(standard_splits) * 1.5)
# График 2: Group Time Series Split
ax = axes[1]
for i, (train_idx, test_idx, train_groups, test_groups) in enumerate(group_splits):
train_data = data.iloc[train_idx]
test_data = data.iloc[test_idx]
y_position = i * 1.5
for comp_idx, company in enumerate(companies):
# Train
train_comp = train_data[train_data['company'] == company]
if len(train_comp) > 0:
ax.plot([train_comp.index.min(), train_comp.index.max()], 
[y_position + comp_idx * 0.25, y_position + comp_idx * 0.25],
color=colors[comp_idx], linewidth=4, alpha=0.6, solid_capstyle='butt',
label=f'{company} (train)' if i == 0 else '')
# Test
test_comp = test_data[test_data['company'] == company]
if len(test_comp) > 0:
ax.plot([test_comp.index.min(), test_comp.index.max()], 
[y_position + comp_idx * 0.25, y_position + comp_idx * 0.25],
color=colors[comp_idx], linewidth=6, alpha=0.95, solid_capstyle='butt')
# Аннотация - какие компании в test
test_companies = ', '.join(sorted(test_groups))
ax.text(data.index.max(), y_position + 0.5, f'Test: {test_companies}', 
fontsize=9, va='center', ha='right', 
bbox=dict(boxstyle='round,pad=0.5', facecolor='yellow', 
edgecolor='orange', alpha=0.8, linewidth=2))
ax.set_yticks([i * 1.5 + 0.5 for i in range(len(group_splits))], 
[f"Фолд {j+1}" for j in range(len(group_splits))])
ax.set_title("B) Group Time Series Split ✓\n" + 
"На каждом фолде РАЗНЫЕ компании в test → проверка обобщения на различные группы\n" +
"→ НЕТ пересечения групп между train/test", 
fontsize=13, fontweight='bold', pad=15)
ax.grid(True, axis='x', alpha=0.3, linestyle='--')
ax.set_xlabel("Дата", fontsize=12, fontweight='bold')
ax.set_ylabel('', fontsize=11)
ax.set_ylim(-0.3, len(group_splits) * 1.5)
# Общая легенда для типов линий
from matplotlib.patches import Patch
legend_elements_types = [
plt.Line2D([0], [0], color='gray', linewidth=4, label='Train', alpha=0.6),
plt.Line2D([0], [0], color='gray', linewidth=6, label='Test', alpha=0.95)
]
ax.legend(handles=legend_elements_types, loc='upper left', fontsize=10, framealpha=0.9)
plt.tight_layout()
plt.show()
# Статистика
print("=" * 80)
print("GROUP TIME SERIES SPLIT - АНАЛИЗ")
print("=" * 80)
print()
print(f"Всего компаний: {len(companies)}")
print(f"Компании: {', '.join(companies)}")
print()
for i, (train_idx, test_idx, train_groups, test_groups) in enumerate(group_splits):
train_dates = data.iloc[train_idx].index
test_dates = data.iloc[test_idx].index
print(f"Фолд {i+1}:")
print(f"  Train groups: {', '.join(sorted(train_groups))} ({len(train_groups)} компаний)")
print(f"  Test groups:  {', '.join(sorted(test_groups))} ({len(test_groups)} компании)")
print(f"  Train: {len(train_idx)} наблюдений ({train_dates.min().date()} - {train_dates.max().date()})")
print(f"  Test:  {len(test_idx)} наблюдений ({test_dates.min().date()} - {test_dates.max().date()})")
print()

Сравнение стандартного метода разделения выборок временных рядов TimeSeriesSplit с методом Group Time Series Split для прогнозирования рядов для 5 групп. Стандартный подход (A) разделяет данные только по времени, позволяя всем группам присутствовать в train и test одновременно, что создает риск утечки через межгрупповые корреляции. Group Time Series Split (B) гарантирует, что определенные группы используются только для тестирования на каждом фолде, обеспечивая честную оценку обобщающей способности модели на новые активы

Рис. 6: Сравнение стандартного метода разделения выборок временных рядов TimeSeriesSplit с методом Group Time Series Split для прогнозирования рядов для 5 групп. Стандартный подход (A) разделяет данные только по времени, позволяя всем группам присутствовать в train и test одновременно, что создает риск утечки через межгрупповые корреляции. Group Time Series Split (B) гарантирует, что определенные группы используются только для тестирования на каждом фолде, обеспечивая честную оценку обобщающей способности модели на новые данные

================================================================================
GROUP TIME SERIES SPLIT - АНАЛИЗ
================================================================================
Всего компаний: 5
Компании: Company1, Company2, Company3, Company4, Company5
Фолд 1:
Train groups: Company1, Company2, Company3 (3 компаний)
Test groups:  Company4, Company5 (2 компании)
Train: 348 наблюдений (2023-01-01 - 2023-04-26)
Test:  232 наблюдений (2023-04-27 - 2023-08-20)
Фолд 2:
Train groups: Company1, Company3, Company5 (3 компаний)
Test groups:  Company2, Company4 (2 компании)
Train: 696 наблюдений (2023-01-01 - 2023-08-20)
Test:  232 наблюдений (2023-08-21 - 2023-12-14)
Фолд 3:
Train groups: Company2, Company3, Company5 (3 компаний)
Test groups:  Company1, Company4 (2 компании)
Train: 1044 наблюдений (2023-01-01 - 2023-12-14)
Test:  232 наблюдений (2023-12-15 - 2024-04-08)
Фолд 4:
Train groups: Company2, Company3, Company5 (3 компаний)
Test groups:  Company1, Company4 (2 компании)
Train: 1392 наблюдений (2023-01-01 - 2024-04-08)
Test:  232 наблюдений (2024-04-09 - 2024-08-02)
Фолд 5:
Train groups: Company2, Company4, Company5 (3 компаний)
Test groups:  Company1, Company3 (2 компании)
Train: 1740 наблюдений (2023-01-01 - 2024-08-02)
Test:  232 наблюдений (2024-08-03 - 2024-11-26)

Преимущества и недостатки метода

Плюсы:

  1. Предотвращает групповую утечку данных в модели - гарантирует независимость между train и test на уровне групп;
  2. Тестирует обобщение - оценивает способность модели работать на новых, невиденных группах (активах, клиентах, регионах);
  3. Реалистичность - в продакшене часто нужно применять модель к новым сущностям, не участвовавшим в обучении;
  4. Снижает переобучение - модель не может использовать специфичные для группы паттерны для "подглядывания" в test.

Минусы:

  1. Меньше данных - часть групп исключается, либо неравномерно попадает в выборки. Возможен дисбаланс в объемах train/test, что создает риски недообучения модели;
  2. Требует достаточно групп - нужно минимум 5-10 групп для разумного разделения;
  3. Вариативность - результаты могут зависеть от того, какие конкретно группы попали в test;
  4. Усложняет интерпретацию - нужно дополнительно анализировать различия между группами.

Group Time Series Split активно используется в следующих областях:

  • Портфельный менеджмент - обучение на одних акциях, тест на других для проверки устойчивости стратегии;
  • Multi-asset trading - модели для торговли несколькими инструментами одновременно;
  • Прогнозирование в ритейле - прогноз продаж для разных магазинов/продуктов;
  • Рекомендательные системы - тестирование на новых пользователях или товарах;
  • Медицина - обобщение моделей с одних пациентов/больниц на другие.

Заключение

В данной статье мы рассмотрели продвинутые методы кросс-валидации для временных рядов, которые выходят за рамки стандартного TimeSeriesSplit из scikit-learn. Каждый метод решает специфические проблемы, возникающие при работе со сложными финансовыми данными, высокочастотными рядами или задачами с групповой структурой.

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