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

Бейзлайн (baseline) — это простая стартовая модель, определяющая минимально приемлемый уровень качества следующих ML-моделей. Если другие модели с более сложной архитектурой выдают метрики хуже бейзлайна, значит, их применение неоправданно.

Нередко так бывает, что линейная регрессия с правильными признаками превосходит нейросеть со слабой подготовкой данных. А градиентный бустинг с грамотной регуляризацией обходит LSTM на горизонте прогноза в 1-5 дней для большинства финансовых инструментов. Это значит что бейзлайн модель лучше улавливает закономерности в данных и следует менять либо архитектуру модели, либо подход к ее обучению.

Выбор baseline архитектуры для временных рядов

Сегодня большинство задач прогнозирования покрывают 3 класса моделей:

  • Линейные методы с регуляризацией;
  • Градиентный бустинг;
  • Модели Prophet и ETS.

Выбор зависит от объема данных, горизонта прогноза и требований к интерпретируемости.

Линейные модели остаются сильным бейзлайном при малом количестве данных. Ridge и Lasso регрессии эффективно работают на выборках от 200–300 наблюдений, предотвращая переобучение за счет регуляризации. В Ridge веса коррелированных признаков сжимаются равномерно, а Lasso обнуляет незначимые, автоматически отбирая релевантные переменные. В прогнозировании финансовых рядов Lasso нередко оставляет лишь 10–20 ключевых лагов из сотен, обеспечивая простую и интерпретируемую модель.

Модели Prophet и ETS (Error, Trend, Seasonality) решают задачи с выраженной сезонностью и трендом. Так, к примеру, Prophet декомпозирует ряд на тренд, сезонность и праздники, подходит для бизнес-метрик с недельными и годовыми циклами. ETS тоже моделирует уровень, тренд и сезонность, однако делает это через экспоненциальное сглаживание, тогда как Prophet явно декомпозирует ряд на тренд, сезонность и внешние эффекты (например, праздники).

Модели Prophet и ETS лично я часто использую как бейзлайны. Они хорошо справляются с временными рядами, где есть ярко выраженные зависимости и дают неплохие прогнозы на месяц или квартал. Для краткосрочного прогноза цен активов эти методы уступают моделям градиентного бустинга — финансовые ряды слабо детерминированы и требуют гибкости в захвате нелинейных зависимостей.

Градиентный бустинг уверенно доминирует на средних и больших выборках благодаря высокой эффективности и способности работать с разнородными данными:

  • LightGBM способен обрабатывать миллионы строк за считанные минуты за счет histogram-based алгоритма разбиения и продуманной оптимизации памяти.
  • CatBoost особенно эффективен при наличии категориальных признаков, таких как тикеры, сектора или типы финансовых инструментов. Все благодаря использованию ordered target encoding, который позволяет извлекать информацию из категорий без риска переобучения.

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

import numpy as np
import pandas as pd
from sklearn.linear_model import Ridge, Lasso
from sklearn.preprocessing import StandardScaler
from lightgbm import LGBMRegressor
from catboost import CatBoostRegressor
import yfinance as yf

# Загрузка данных
ticker = yf.Ticker("GOOG")  #Google
data = ticker.history(period="3y", interval="1d")

# Проверка на MultiIndex
if isinstance(data.columns, pd.MultiIndex):
    data.columns = data.columns.droplevel(1)

data = data[['Close', 'Volume']].dropna()
data['returns'] = data['Close'].pct_change()

# Создание лаговых признаков
for lag in [1, 2, 3, 5, 10]:
    data[f'returns_lag_{lag}'] = data['returns'].shift(lag)
    data[f'volume_lag_{lag}'] = data['Volume'].shift(lag)

data = data.dropna()

# Целевая переменная: доходность через 1 день
data['target'] = data['returns'].shift(-1)
data = data.dropna()

# Разделение на признаки и таргет
feature_cols = [col for col in data.columns if 'lag' in col]
X = data[feature_cols]
y = data['target']

# Разделение на train/test
split_idx = int(len(data) * 0.8)
X_train, X_test = X[:split_idx], X[split_idx:]
y_train, y_test = y[:split_idx], y[split_idx:]

# Масштабирование для линейных моделей
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.transform(X_test)

# Ridge регрессия
ridge = Ridge(alpha=1.0, random_state=42)
ridge.fit(X_train_scaled, y_train)

# Lasso регрессия
lasso = Lasso(alpha=0.0001, random_state=42)
lasso.fit(X_train_scaled, y_train)

# LightGBM
lgbm = LGBMRegressor(
    n_estimators=100,
    learning_rate=0.05,
    max_depth=3,
    subsample=0.8,
    colsample_bytree=0.8,
    random_state=42,
    verbose=-1
)
lgbm.fit(X_train, y_train)

# CatBoost
catboost = CatBoostRegressor(
    iterations=100,
    learning_rate=0.05,
    depth=3,
    subsample=0.8,
    random_state=42,
    verbose=0
)
catboost.fit(X_train, y_train)

# Оценка на тестовой выборке
from sklearn.metrics import mean_absolute_error, mean_squared_error

models = {
    'Ridge': (ridge, X_test_scaled),
    'Lasso': (lasso, X_test_scaled),
    'LightGBM': (lgbm, X_test),
    'CatBoost': (catboost, X_test)
}

for name, (model, X_test_data) in models.items():
    y_pred = model.predict(X_test_data)
    mae = mean_absolute_error(y_test, y_pred)
    rmse = np.sqrt(mean_squared_error(y_test, y_pred))
    print(f"{name:12} MAE: {mae:.6f}, RMSE: {rmse:.6f}")
Ridge        MAE: 0.013874, RMSE: 0.020179
Lasso        MAE: 0.013928, RMSE: 0.020189
LightGBM     MAE: 0.014496, RMSE: 0.020402
CatBoost     MAE: 0.014140, RMSE: 0.020149

Представленный выше код демонстрирует сравнение четырех baseline архитектур на данных акций Google за последние 3 года. Ridge и Lasso получают масштабированные признаки через StandardScaler — линейные модели чувствительны к разбросу значений. LightGBM и CatBoost работают с исходными данными, tree-based алгоритмы инвариантны к масштабу.

Параметры регуляризации установлены консервативно:

  • alpha=1.0 для Ridge;
  • alpha=0.0001 для Lasso;
  • max_depth=3 для бустинга.

Мелкие деревья снижают риск переобучения на финансовых данных с высоким уровнем шума. Гиперпараметры subsample=0.8 и colsample_bytree=0.8 добавляют стохастичность, улучшая генерализацию.

Результаты показывают MAE и RMSE на тестовой выборке. Для временных рядов доходностей MAE интерпретируется как средняя ошибка прогноза в процентах. RMSE больше штрафует крупные промахи — что более актуально для управления рисками. Если разница между моделями менее 5-10%, выбираем более простую архитектуру.

Инжиниринг признаков для временных рядов

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

Лаговые переменные и скользящие окна

Лаговые признаки захватывают автокорреляцию — зависимость текущего значения от прошлых. Для дневных данных lag_1 означает вчерашнее значение, lag_5 — неделю назад с учетом торговых дней. Выбор горизонта лагов зависит от частоты данных и характера зависимостей.

Финансовые временные ряды демонстрируют короткую память: автокорреляция доходностей угасает через 1-10 дней. Для решения этой проблемы используются лаги разной периодичности:

  • Включение lag_1 до lag_10 покрывает релевантный диапазон;
  • Добавляются среднесрочные лаги: lag_20, lag_30 (хотя они обычно не улучшают качество — сигнал растворяется в шуме);
  • Для внутридневных данных горизонт сокращается до lag_1…lag_50 баров по 5 минут.

Скользящие (Rolling) статистики агрегируют информацию по свигающимся окнам по ряду. Так, к примеру:

  • rolling_mean_5 усредняет 5 последних значений;
  • rolling_std_10 измеряет волатильность на 10 рядах.

Эти признаки сглаживают шум и выделяют тренды. Rolling_min и rolling_max определяют границы недавнего диапазона — полезно для стратегий возврата к среднему (mean-reversion).

import pandas as pd
import numpy as np
import yfinance as yf
pd.set_option('display.expand_frame_repr', False)

# Загрузка данных
ticker = yf.Ticker("TSM")
data = ticker.history(period="2y", interval="1d")

if isinstance(data.columns, pd.MultiIndex):
    data.columns = data.columns.droplevel(1)

data = data[['Close', 'Volume', 'High', 'Low']].dropna()
data['returns'] = data['Close'].pct_change()

# Лаговые признаки для доходностей
for lag in range(1, 11):
    data[f'returns_lag_{lag}'] = data['returns'].shift(lag)

# Лаговые признаки для объема
for lag in [1, 5, 10]:
    data[f'volume_lag_{lag}'] = data['Volume'].shift(lag)

# Rolling статистики
windows = [5, 10, 20]
for window in windows:
    data[f'returns_rolling_mean_{window}'] = data['returns'].rolling(window).mean()
    data[f'returns_rolling_std_{window}'] = data['returns'].rolling(window).std()
    data[f'price_rolling_min_{window}'] = data['Close'].rolling(window).min()
    data[f'price_rolling_max_{window}'] = data['Close'].rolling(window).max()

# Относительная позиция цены в диапазоне
for window in windows:
    price_min = data['Close'].rolling(window).min()
    price_max = data['Close'].rolling(window).max()
    data[f'price_position_{window}'] = (data['Close'] - price_min) / (price_max - price_min + 1e-8)

# High-Low spread как мера внутридневной волатильности
data['hl_spread'] = (data['High'] - data['Low']) / data['Close']
data['hl_spread_rolling_mean_5'] = data['hl_spread'].rolling(5).mean()

# Удаление строк с NaN
data = data.dropna()

print(f"Количество признаков: {len([col for col in data.columns if 'lag' in col or 'rolling' in col or 'position' in col or 'spread' in col])}")
print(f"Размер выборки: {len(data)}")
print("\nПримеры признаков:")
print(data[[col for col in data.columns if 'lag' in col or 'rolling' in col]].head())
Количество признаков: 30
Размер выборки: 480

Примеры признаков:
                           returns_lag_1  returns_lag_2  returns_lag_3  returns_lag_4  returns_lag_5  returns_lag_6  returns_lag_7  returns_lag_8  returns_lag_9  returns_lag_10  ...  price_rolling_max_5  returns_rolling_mean_10  returns_rolling_std_10  price_rolling_min_10  price_rolling_max_10  returns_rolling_mean_20  returns_rolling_std_20  price_rolling_min_20  price_rolling_max_20  hl_spread_rolling_mean_5
Date                                                                                                                                                                              ...                                                                                                                                                                                                                                         
2023-11-20 00:00:00-05:00       0.010554      -0.002631      -0.001112       0.025825      -0.010468       0.063523      -0.004130      -0.004437      -0.002052        0.008824  ...            97.365601                 0.007889                0.022021             89.242065             97.365601                 0.004850                0.020857             83.758186             97.365601                  0.015462
2023-11-21 00:00:00-05:00       0.003816       0.010554      -0.002631      -0.001112       0.025825      -0.010468       0.063523      -0.004130      -0.004437       -0.002052  ...            97.365601                 0.006543                0.023082             89.242065             97.365601                 0.003784                0.021344             83.758186             97.365601                  0.015825
2023-11-22 00:00:00-05:00      -0.015506       0.003816       0.010554      -0.002631      -0.001112       0.025825      -0.010468       0.063523      -0.004130       -0.004437  ...            97.365601                 0.007231                0.022819             89.242065             97.365601                 0.006088                0.018212             83.758186             97.365601                  0.014656
2023-11-24 00:00:00-05:00       0.002439      -0.015506       0.003816       0.010554      -0.002631      -0.001112       0.025825      -0.010468       0.063523       -0.004130  ...            97.365601                 0.006813                0.023087             93.917473             97.365601                 0.005781                0.018409             83.758186             97.365601                  0.014817
2023-11-27 00:00:00-05:00      -0.008312       0.002439      -0.015506       0.003816       0.010554      -0.002631      -0.001112       0.025825      -0.010468        0.063523  ...            97.365601                -0.000173                0.011860             93.917473             97.365601                 0.006299                0.017881             83.826363             97.365601                  0.014330

[5 rows x 26 columns]

Пример кода Python генерирует 30 признаков из базовых OHLC данных. Лаги returns охватывают 10 дней — достаточно для захвата краткосрочной памяти. Rolling статистики вычисляются на окнах 5, 10, 20 дней, соответствующих торговой неделе, двум неделям и месяцу.

👉🏻  Гомоскедастичность и Гетероскедастичность временных рядов

Признак Price_position измеряет текущую цену относительно минимума и максимума на окне:

  • Значение 0 означает цену на минимуме;
  • 1 — на максимуме;
  • 0.5 — в середине диапазона.

Этот признак помогает модели определять перекупленность и перепроданность.

Признак High-Low spread отражает внутридневную волатильность — расстояние между максимумом и минимумом дня. Скользящее среднее spread сглаживает ежедневные всплески и показывает тренд изменения волатильности. Рост spread предшествует периодам высокой неопределенности.

Календарные и циклические признаки

Временные ряды содержат календарные паттерны:

  • День недели влияет на объем торгов;
  • Час дня определяет ликвидность;
  • Месяц года коррелирует с налоговыми периодами и ребалансировкой портфелей.

Прямое кодирование времени как целого числа (1, 2, 3, 4, 5 для дней недели) разрывает цикличность — модель не понимает, что понедельник следует за пятницей.

Для сохранения цикличности применяют Синус-косинус трансформацию. День недели преобразуется в две переменные:

sin(2π × day / 7)

cos(2π × day / 7)

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

Бинарные признаки отмечают специфичные события: is_month_start, is_month_end, is_quarter_end. Конец квартала сопровождается феноменом window dressing — фондовые менеджеры корректируют портфели для отчетности. Начало месяца характеризуется притоком капитала от институциональных инвесторов.

import pandas as pd
import numpy as np
import yfinance as yf

# Загрузка внутридневных данных
ticker = yf.Ticker("ASML")
data = ticker.history(period="60d", interval="1h")

if isinstance(data.columns, pd.MultiIndex):
    data.columns = data.columns.droplevel(1)

data = data[['Close', 'Volume']].dropna()
data['returns'] = data['Close'].pct_change()

# Извлечение временных компонент
data['hour'] = data.index.hour
data['day_of_week'] = data.index.dayofweek
data['day_of_month'] = data.index.day
data['month'] = data.index.month
data['week_of_year'] = data.index.isocalendar().week

# Синус-косинус кодирование для цикличности
data['hour_sin'] = np.sin(2 * np.pi * data['hour'] / 24)
data['hour_cos'] = np.cos(2 * np.pi * data['hour'] / 24)

data['day_of_week_sin'] = np.sin(2 * np.pi * data['day_of_week'] / 7)
data['day_of_week_cos'] = np.cos(2 * np.pi * data['day_of_week'] / 7)

data['month_sin'] = np.sin(2 * np.pi * data['month'] / 12)
data['month_cos'] = np.cos(2 * np.pi * data['month'] / 12)

# Бинарные признаки для специфичных периодов
data['is_month_start'] = (data.index.day <= 3).astype(int) data['is_month_end'] = (data.index.day >= 28).astype(int)
data['is_quarter_end'] = data.index.month.isin([3, 6, 9, 12]).astype(int)

# Признаки для торговых сессий
data['is_market_open'] = ((data['hour'] >= 9) & (data['hour'] < 16)).astype(int)
data['is_first_hour'] = (data['hour'] == 9).astype(int)
data['is_last_hour'] = (data['hour'] == 15).astype(int)

data = data.dropna()

print("Календарные признаки:")
calendar_features = [col for col in data.columns if 'sin' in col or 'cos' in col or 'is_' in col]
print(data[calendar_features].head(10))

print(f"\nКорреляция hour_sin и hour_cos с доходностью:")
print(data[['returns', 'hour_sin', 'hour_cos']].corr()['returns'][1:])
Календарные признаки:
                               hour_sin  hour_cos  day_of_week_sin  day_of_week_cos  month_sin  month_cos  is_month_start  is_month_end  is_quarter_end  is_market_open  is_first_hour  is_last_hour
Datetime                                                                                                                                                                                            
2025-07-28 10:30:00-04:00  5.000000e-01 -0.866025         0.000000          1.00000       -0.5  -0.866025               0             1               0               1              0             0
2025-07-28 11:30:00-04:00  2.588190e-01 -0.965926         0.000000          1.00000       -0.5  -0.866025               0             1               0               1              0             0
2025-07-28 12:30:00-04:00  1.224647e-16 -1.000000         0.000000          1.00000       -0.5  -0.866025               0             1               0               1              0             0
2025-07-28 13:30:00-04:00 -2.588190e-01 -0.965926         0.000000          1.00000       -0.5  -0.866025               0             1               0               1              0             0
2025-07-28 14:30:00-04:00 -5.000000e-01 -0.866025         0.000000          1.00000       -0.5  -0.866025               0             1               0               1              0             0
2025-07-28 15:30:00-04:00 -7.071068e-01 -0.707107         0.000000          1.00000       -0.5  -0.866025               0             1               0               1              0             1
2025-07-29 09:30:00-04:00  7.071068e-01 -0.707107         0.781831          0.62349       -0.5  -0.866025               0             1               0               1              1             0
2025-07-29 10:30:00-04:00  5.000000e-01 -0.866025         0.781831          0.62349       -0.5  -0.866025               0             1               0               1              0             0
2025-07-29 11:30:00-04:00  2.588190e-01 -0.965926         0.781831          0.62349       -0.5  -0.866025               0             1               0               1              0             0
2025-07-29 12:30:00-04:00  1.224647e-16 -1.000000         0.781831          0.62349       -0.5  -0.866025               0             1               0               1              0             0

Корреляция hour_sin и hour_cos с доходностью:
hour_sin    0.154682
hour_cos    0.171963
Name: returns, dtype: float64

Код создает признаки для внутридневных данных ASML. Синус-косинус трансформация применяется к часу дня, дню недели и месяцу. Пара (sin, cos) полностью описывает положение на цикле без разрывов.

Бинарные флаги is_market_open, is_first_hour, is_last_hour выделяют торговые сессии. Первый час характеризуется повышенной волатильностью из-за обработки ночных новостей. Последний час демонстрирует рост объемов — закрытие позиций перед выходными или праздниками.

Корреляционный анализ показывает связь календарных признаков с доходностью. Тут важно отметить, что слабая корреляция (|r| < 0.05) не означает бесполезность признака — многие нелинейные модели извлекают паттерны, невидимые для линейной корреляции. Так, к примеру, градиентные бустинги обычно находят взаимодействия между hour_sin и volume_lag_1, улучшая предсказание внутридневных движений.

Технические индикаторы как признаки

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

  • Моментум (Momentum). Этот индикатор измеряет скорость изменения цены;
  • Реализованная волатильность и ATR — количественно описывают риски;
  • Относительные изменения цен нормализуют данные для сравнения разных инструментов.

Momentum вычисляется как разница текущей цены и цены N периодов назад, деленная на цену N периодов назад:

(Close_t — Close_{t-N}) / Close_{t-N}.

Положительный моментум указывает на восходящий тренд, отрицательный — на нисходящий. Окна 5, 10, 20 дней захватывают индикатор на разных масштабах.

Реализованная волатильность оценивается через стандартное отклонение доходностей на скользящем окне. Рост волатильности предшествует крупным движениям цены в любую сторону. Отношение текущей волатильности к исторической (volatility_ratio) нормализует метрику и упрощает сравнение периодов.

import pandas as pd
import numpy as np
import yfinance as yf

# Загрузка данных
ticker = yf.Ticker("AMD")
data = ticker.history(period="3y", interval="1d")

if isinstance(data.columns, pd.MultiIndex):
    data.columns = data.columns.droplevel(1)

data = data[['Close', 'Volume', 'High', 'Low']].dropna()
data['returns'] = data['Close'].pct_change()

# Momentum на разных окнах
momentum_windows = [5, 10, 20, 50]
for window in momentum_windows:
    data[f'momentum_{window}'] = (data['Close'] - data['Close'].shift(window)) / data['Close'].shift(window)

# Реализованная волатильность
volatility_windows = [5, 10, 20]
for window in volatility_windows:
    data[f'volatility_{window}'] = data['returns'].rolling(window).std()

# Отношение текущей волатильности к исторической
data['volatility_ratio'] = data['volatility_5'] / (data['volatility_20'] + 1e-8)

# Относительное изменение объема
data['volume_change'] = data['Volume'].pct_change()
for window in [5, 10]:
    data[f'volume_momentum_{window}'] = (data['Volume'] - data['Volume'].shift(window)) / data['Volume'].shift(window)

# Average True Range (волатильность с учетом гэпов)
data['true_range'] = np.maximum(
    data['High'] - data['Low'],
    np.maximum(
        np.abs(data['High'] - data['Close'].shift(1)),
        np.abs(data['Low'] - data['Close'].shift(1))
    )
)
data['atr_14'] = data['true_range'].rolling(14).mean()

# Нормализованный ATR
data['atr_normalized'] = data['atr_14'] / data['Close']

# Относительная сила (upside vs downside momentum)
up_returns = data['returns'].clip(lower=0)
down_returns = -data['returns'].clip(upper=0)

for window in [10, 20]:
    up_mean = up_returns.rolling(window).mean()
    down_mean = down_returns.rolling(window).mean()
    data[f'relative_strength_{window}'] = up_mean / (down_mean + 1e-8)

data = data.dropna()

print("Технические индикаторы:")
technical_features = [col for col in data.columns if 'momentum' in col or 'volatility' in col or 'atr' in col or 'relative_strength' in col]
print(data[technical_features].tail(10))

# Анализ волатильности в разные периоды
high_vol_periods = data[data['volatility_ratio'] > 1.5]
low_vol_periods = data[data['volatility_ratio'] < 0.7] print(f"\nПериоды высокой волатильности (>1.5x): {len(high_vol_periods)}")
print(f"Периоды низкой волатильности (<0.7x): {len(low_vol_periods)}")
Технические индикаторы:
                           momentum_5  momentum_10  momentum_20  momentum_50  volatility_5  volatility_10  volatility_20  volatility_ratio  volume_momentum_5  volume_momentum_10     atr_14  atr_normalized  relative_strength_10  relative_strength_20
Date                                                                                                                                                                                                                                                    
2025-10-07 00:00:00-04:00    0.307312     0.314543     0.357399     0.217955      0.103273       0.075468       0.054730          1.886940           2.901275            1.934172  10.328570        0.048833              8.284096              4.741110
2025-10-08 00:00:00-04:00    0.436254     0.464197     0.476495     0.327547      0.102027       0.078928       0.058883          1.732708           3.010094            3.159294  11.461427        0.048656             11.080096              5.750359
2025-10-09 00:00:00-04:00    0.372120     0.444100     0.496049     0.297365      0.108867       0.079797       0.058428          1.863283           0.699691            1.561558  11.934999        0.051247              8.635701              6.726995
2025-10-10 00:00:00-04:00    0.305034     0.347673     0.355237     0.218876      0.121077       0.086919       0.062492          1.937468           1.778902            2.913876  13.058571        0.060766              3.819915              3.216558
2025-10-13 00:00:00-04:00    0.062393     0.341225     0.342889     0.260454      0.069905       0.087065       0.062532          1.117919          -0.746427            0.584381  13.423572        0.062026              3.779017              3.156165
2025-10-14 00:00:00-04:00    0.031110     0.347982     0.359155     0.233680      0.068585       0.086885       0.062378          1.099509          -0.384730            1.400336  13.595714        0.062340              3.821685              3.299968
2025-10-15 00:00:00-04:00    0.012905     0.454789     0.499120     0.368826      0.061171       0.088559       0.064353          0.950542          -0.321924            1.719149  14.617143        0.061262              4.500120              4.157236
2025-10-16 00:00:00-04:00    0.007171     0.381960     0.485309     0.437960      0.061572       0.090480       0.064610          0.952989          -0.260517            0.256893  14.895714        0.063505              3.679340              3.903939
2025-10-17 00:00:00-04:00    0.084598     0.415437     0.480907     0.351972      0.044202       0.088864       0.064674          0.683457          -0.529700            0.306917  15.083571        0.064714              4.452642              3.828568
2025-10-20 00:00:00-04:00    0.111542     0.180894     0.505476     0.392452      0.044196       0.055298       0.064696          0.683141          -0.102783           -0.772490  15.572857        0.064736              2.619499              3.938667

Периоды высокой волатильности (>1.5x): 56
Периоды низкой волатильности (<0.7x): 214

Код генерирует индикаторы momentum, volatility и relative strength для акций AMD. Momentum вычисляется на окнах от 5 до 50 дней — краткосрочные сигналы и долгосрочные тренды. Отрицательный momentum_5 при положительном momentum_20 указывает на коррекцию внутри восходящего тренда.

👉🏻  Настройки градиентного бустинга. Гиперпараметры бустинговых ML-моделей

Average True Range учитывает гэпы между днями — расстояние между закрытием вчера и открытием сегодня. True range определяется как максимум из трех величин:

  • внутридневной диапазон (High — Low);
  • гэп вверх (High — Close_prev);
  • гэп вниз (Low — Close_prev).

ATR усредняет true range на 14 днях и нормализуется делением на цену.

Relative strength сравнивает среднюю величину роста и падения на окне. Значение выше 1 означает преобладание восходящих движений, ниже 1 — нисходящих. В отличие от классического RSI, этот индикатор не ограничен диапазоном 0-100 и не требует пороговых значений для интерпретации — модель самостоятельно находит значимые уровни.

Регуляризация для временных рядов

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

L1 и L2 регуляризация

L2-регуляризация (Ridge) добавляет к функции потерь штраф за величину весов. Рассчитывается по формуле:

Loss = MSE + α × Σw².

Параметр α контролирует силу регуляризации — большие значения сильнее сжимают веса. Для временных рядов α подбирается через кросс-валидацию в диапазоне [0.01, 100].

L1-регуляризация (Lasso) использует штраф:

α × Σ|w|

Данный штраф приводит к разреженности — часть весов обнуляется. Lasso выполняет автоматический отбор признаков: из 100 лаговых переменных остаются 10-15 значимых. Для финансовых данных с высокой коррелированностью лагов Lasso выбирает подмножество без потери информации.

Elastic Net комбинирует L1 и L2:

Loss = MSE + α₁ × Σ|w| + α₂ × Σw².

Параметр l1_ratio определяет баланс между двумя типами регуляризации. Значение 0.5 дает равный вес L1 и L2, подходит для данных с группами коррелированных признаков.

import numpy as np
import pandas as pd
from sklearn.linear_model import Ridge, Lasso, ElasticNet
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import TimeSeriesSplit
from sklearn.metrics import mean_squared_error
import yfinance as yf

# Загрузка данных
ticker = yf.Ticker("INTC")
data = ticker.history(period="3y", interval="1d")

if isinstance(data.columns, pd.MultiIndex):
    data.columns = data.columns.droplevel(1)

data = data[['Close', 'Volume']].dropna()
data['returns'] = data['Close'].pct_change()

# Feature engineering
for lag in range(1, 21):
    data[f'returns_lag_{lag}'] = data['returns'].shift(lag)

for window in [5, 10, 20]:
    data[f'returns_rolling_mean_{window}'] = data['returns'].rolling(window).mean()
    data[f'returns_rolling_std_{window}'] = data['returns'].rolling(window).std()

data['target'] = data['returns'].shift(-1)
data = data.dropna()

# Признаки и таргет
feature_cols = [col for col in data.columns if 'lag' in col or 'rolling' in col]
X = data[feature_cols]
y = data['target']

# Масштабирование
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)

# Time series cross-validation для подбора alpha
tscv = TimeSeriesSplit(n_splits=5)

# Подбор alpha для Ridge
alphas_ridge = [0.01, 0.1, 1.0, 10.0, 100.0]
ridge_scores = []

for alpha in alphas_ridge:
    scores = []
    for train_idx, val_idx in tscv.split(X_scaled):
        X_train, X_val = X_scaled[train_idx], X_scaled[val_idx]
        y_train, y_val = y.iloc[train_idx], y.iloc[val_idx]
        
        model = Ridge(alpha=alpha)
        model.fit(X_train, y_train)
        y_pred = model.predict(X_val)
        scores.append(mean_squared_error(y_val, y_pred))
    
    ridge_scores.append(np.mean(scores))

best_alpha_ridge = alphas_ridge[np.argmin(ridge_scores)]
print(f"Лучший alpha для Ridge: {best_alpha_ridge}, RMSE: {np.sqrt(min(ridge_scores)):.6f}")

# Подбор alpha для Lasso
alphas_lasso = [0.00001, 0.0001, 0.001, 0.01, 0.1]
lasso_scores = []

for alpha in alphas_lasso:
    scores = []
    for train_idx, val_idx in tscv.split(X_scaled):
        X_train, X_val = X_scaled[train_idx], X_scaled[val_idx]
        y_train, y_val = y.iloc[train_idx], y.iloc[val_idx]
        
        model = Lasso(alpha=alpha, max_iter=10000)
        model.fit(X_train, y_train)
        y_pred = model.predict(X_val)
        scores.append(mean_squared_error(y_val, y_pred))
    
    lasso_scores.append(np.mean(scores))

best_alpha_lasso = alphas_lasso[np.argmin(lasso_scores)]
print(f"Лучший alpha для Lasso: {best_alpha_lasso}, RMSE: {np.sqrt(min(lasso_scores)):.6f}")

# Обучение финальной Lasso модели
lasso_final = Lasso(alpha=best_alpha_lasso, max_iter=10000)
lasso_final.fit(X_scaled, y)

# Анализ отобранных признаков
selected_features = np.abs(lasso_final.coef_) > 0.0001
print(f"\nLasso отобрал {selected_features.sum()} из {len(feature_cols)} признаков")
print("\nТоп-10 признаков по важности:")
feature_importance = pd.DataFrame({
    'feature': np.array(feature_cols)[selected_features],
    'coef': lasso_final.coef_[selected_features]
}).sort_values('coef', key=abs, ascending=False)
print(feature_importance.head(10))
Лучший alpha для Ridge: 100.0, RMSE: 0.035134
Лучший alpha для Lasso: 0.01, RMSE: 0.034327

Lasso отобрал 3 из 26 признаков

Топ-10 признаков по важности:
Empty DataFrame
Columns: [feature, coef]
Index: [returns_rolling_mean_5, returns_lag_3, returns_lag_7]

Код демонстрирует подбор коэффициента регуляризации через time series cross-validation на данных Intel. TimeSeriesSplit создает 5 фолдов с сохранением временного порядка — каждый следующий фолд использует больше обучающих данных, предотвращая возможную утечку данных в будущее (look-ahead bias).

Ridge тестируется на диапазоне alpha от 0.01 до 100:

  • Малые значения (0.01-0.1) применяют слабую регуляризацию, подходят для данных с низким шумом;
  • Большие значения (10-100) агрессивно сжимают веса, защищают от переобучения на зашумленных финансовых рядах.

Lasso требует меньших alpha (0.00001-0.1) из-за более сильного эффекта L1-штрафа.

Анализ отобранных признаков показывает, какие лаги и rolling статистики значимы для прогноза. Lasso обнуляет коррелированные переменные — если returns_lag_1 и returns_lag_2 сильно коррелированы, модель оставляет один из них. Топ-10 признаков по абсолютной величине коэффициентов указывают на ключевые временные зависимости в данных.

👉🏻  Жадные алгоритмы: базовые принципы и их применение в количественном анализе

Dropout и Early stopping в градиентном бустинге

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

За стохастику в бустинговых ML-моделях отвечают следующие гиперпараметры:

  • Subsample контролирует долю строк для обучения каждого дерева. Значение 0.8 означает, что каждое дерево видит 80% случайно выбранных наблюдений. Для временных рядов это вносит вариативность без нарушения временного порядка — сэмплирование происходит внутри каждого батча.
  • Colsample_bytree определяет долю признаков, доступных для построения дерева. Значение 0.6 ограничивает модель 60% случайно выбранных переменных на каждой итерации. Это предотвращает доминирование нескольких сильных признаков и заставляет ансамбль использовать разнообразные паттерны.
  • Early stopping прекращает обучение при отсутствии улучшения на валидационной выборке. Параметр early_stopping_rounds=50 останавливает процесс, если validation loss не снижается 50 итераций подряд. Для временных рядов валидация выполняется на последних 20% данных, сохраняя хронологический порядок.
import numpy as np
import pandas as pd
from lightgbm import LGBMRegressor, early_stopping
from sklearn.metrics import mean_squared_error
import matplotlib.pyplot as plt

# Генерация синтетического ряда
np.random.seed(24)
n = 1000
time = np.arange(n)

# Линейный тренд + синусоидальная сезонность + шум
trend = time * 0.01
seasonality = 0.5 * np.sin(2 * np.pi * time / 50)
noise = np.random.normal(0, 0.3, n)

series = trend + seasonality + noise

data = pd.DataFrame({'value': series})

# Feature engineering
# Лаги
for lag in range(1, 21):
    data[f'value_lag_{lag}'] = data['value'].shift(lag)

# Скользящие характеристики
for window in [5, 10, 20]:
    data[f'value_rolling_mean_{window}'] = data['value'].rolling(window).mean()
    data[f'value_rolling_std_{window}'] = data['value'].rolling(window).std()

# Целевая переменная: через 1 шаг
data['target'] = data['value'].shift(-1)
data = data.dropna()

feature_cols = [col for col in data.columns if 'lag' in col or 'rolling' in col]
X = data[feature_cols]
y = data['target']

# Train/validation/test split
train_size = int(len(data) * 0.7)
val_size = int(len(data) * 0.15)

X_train = X[:train_size]
y_train = y[:train_size]
X_val = X[train_size:train_size + val_size]
y_val = y[train_size:train_size + val_size]
X_test = X[train_size + val_size:]
y_test = y[train_size + val_size:]

# Конфиги LightGBM с разной регуляризацией
configs = [
    {
        'name': 'Weak regularization',
        'subsample': 1.0,
        'colsample_bytree': 1.0,
        'max_depth': 12,
        'reg_alpha': 0,
        'reg_lambda': 0,
        'learning_rate': 0.01,
        'n_estimators': 1000
    },
    {
        'name': 'Medium regularization',
        'subsample': 0.6,
        'colsample_bytree': 0.6,
        'max_depth': 6,
        'reg_alpha': 1,
        'reg_lambda': 2,
        'learning_rate': 0.01,
        'n_estimators': 1500
    },
    {
        'name': 'Strong regularization',
        'subsample': 0.4,
        'colsample_bytree': 0.4,
        'max_depth': 2,
        'reg_alpha': 2,
        'reg_lambda': 5,
        'learning_rate': 0.01,
        'n_estimators': 2000
    }
]

results = []

# Обучение моделей
for config in configs:
    model = LGBMRegressor(
        n_estimators=config['n_estimators'],
        learning_rate=config['learning_rate'],
        max_depth=config['max_depth'],
        subsample=config['subsample'],
        colsample_bytree=config['colsample_bytree'],
        reg_alpha=config['reg_alpha'],
        reg_lambda=config['reg_lambda'],
        random_state=24,
        verbose=-1
    )
    
    model.fit(
        X_train, y_train,
        eval_set=[(X_val, y_val)],
        eval_metric='rmse',
        callbacks=[early_stopping(stopping_rounds=100, verbose=False)]
    )
    
    # Предсказания
    y_train_pred = model.predict(X_train)
    y_val_pred = model.predict(X_val)
    y_test_pred = model.predict(X_test)
    
    # Метрики
    train_rmse = np.sqrt(mean_squared_error(y_train, y_train_pred))
    val_rmse = np.sqrt(mean_squared_error(y_val, y_val_pred))
    test_rmse = np.sqrt(mean_squared_error(y_test, y_test_pred))
    
    results.append({
        'Config': config['name'],
        'Trees': model.best_iteration_,
        'Train RMSE': train_rmse,
        'Val RMSE': val_rmse,
        'Test RMSE': test_rmse,
        'Overfit': train_rmse / test_rmse
    })

# Вывод результатов
results_df = pd.DataFrame(results)
print(results_df.to_string(index=False))

# Визуализация
fig, ax = plt.subplots(figsize=(9, 5))
x_pos = np.arange(len(results_df))
width = 0.25

ax.bar(x_pos - width, results_df['Train RMSE'], width, label='Train', color='#2C3E50')
ax.bar(x_pos, results_df['Val RMSE'], width, label='Validation', color='#7F8C8D')
ax.bar(x_pos + width, results_df['Test RMSE'], width, label='Test', color='#95A5A6')

ax.set_xlabel('Configuration')
ax.set_ylabel('RMSE')
ax.set_title('Регуляризация Градиентного бустинга: Воздействие на переобучение')
ax.set_xticks(x_pos)
ax.set_xticklabels(results_df['Config'], rotation=15, ha='right')
ax.legend()
ax.grid(axis='y', alpha=0.3)

plt.tight_layout()
plt.show()
               Config  Trees  Train RMSE  Val RMSE  Test RMSE  Overfit
  Weak regularization    553    0.160315  1.090539   2.526468 0.063454
Medium regularization   1030    0.202766  1.103364   2.544447 0.079690
Strong regularization    738    0.310814  1.045300   2.453614 0.126676

Влияние регуляризации на переобучение gradient boosting. Три группы столбцов показывают RMSE на train, validation и test выборках для разных конфигураций. Weak regularization демонстрирует низкий train RMSE и высокий test RMSE — классический признак переобучения. Strong regularization выравнивает метрики между выборками, подтверждая лучшую генерализацию

Рис. 1: Влияние регуляризации на переобучение gradient boosting. Три группы столбцов показывают RMSE на train, validation и test выборках для разных конфигураций. Weak regularization демонстрирует низкий train RMSE и высокий test RMSE — классический признак переобучения. Strong regularization выравнивает метрики между выборками, подтверждая лучшую генерализацию

Регуляризация с учетом природы временных рядов (Time-series specific regularization)

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

  • Min_data_in_leaf контролирует минимальное количество наблюдений в листе дерева — защита от создания правил на основе единичных выбросов. Для дневных данных min_data_in_leaf=20 гарантирует, что каждое правило основано минимум на месяце наблюдений. Для внутридневных баров по 5 минут значение увеличивается до 50-100 — один торговый день должен содержать достаточно точек для статистически значимого паттерна.
  • Max_depth ограничивает глубину дерева и сложность взаимодействий признаков. Глубокие деревья (depth > 5) захватывают сложные нелинейности, но легко переобучаются на шуме. Для baseline моделей max_depth=3 оптимален — позволяет находить взаимодействия второго порядка без избыточной сложности.
  • Lambda_l1 и Lambda_l2 применяют L1 и L2 регуляризацию к весам листьев в LightGBM. Lambda_l2=1.0 сжимает веса крайних листьев, снижая влияние редких паттернов. Lambda_l1=0.5 дополнительно обнуляет незначимые листья, упрощая модель.
import numpy as np
import pandas as pd
from lightgbm import LGBMRegressor
from sklearn.metrics import mean_squared_error
import yfinance as yf

# Загрузка данных
ticker = yf.Ticker("BABA")  #Alibaba Corp.
data = ticker.history(period="3y", interval="1d")

if isinstance(data.columns, pd.MultiIndex):
    data.columns = data.columns.droplevel(1)

data = data[['Close', 'Volume']].dropna()
data['returns'] = data['Close'].pct_change()

# Feature engineering
for lag in range(1, 11):
    data[f'returns_lag_{lag}'] = data['returns'].shift(lag)

for window in [5, 10, 20]:
    data[f'returns_rolling_mean_{window}'] = data['returns'].rolling(window).mean()
    data[f'returns_rolling_std_{window}'] = data['returns'].rolling(window).std()

# Целевая переменная — доходность через 5 дней
data['target'] = data['returns'].shift(-5)
data = data.dropna()

feature_cols = [col for col in data.columns if 'lag' in col or 'rolling' in col]
X = data[feature_cols]
y = data['target']

# Split
train_size = int(len(data) * 0.8)
X_train, X_test = X[:train_size], X[train_size:]
y_train, y_test = y[:train_size], y[train_size:]

# Конфигурации
ts_configs = [
    {'name': 'Default', 'min_data_in_leaf': 20, 'max_depth': 3, 'lambda_l1': 0.0, 'lambda_l2': 0.0},
    {'name': 'Aggressive min_data', 'min_data_in_leaf': 50, 'max_depth': 3, 'lambda_l1': 0.0, 'lambda_l2': 0.0},
    {'name': 'Shallow trees', 'min_data_in_leaf': 20, 'max_depth': 2, 'lambda_l1': 0.0, 'lambda_l2': 0.0},
    {'name': 'L1+L2 on leaves', 'min_data_in_leaf': 20, 'max_depth': 3, 'lambda_l1': 0.5, 'lambda_l2': 1.0},
    {'name': 'Combined', 'min_data_in_leaf': 40, 'max_depth': 2, 'lambda_l1': 0.5, 'lambda_l2': 1.0}
]

results = []

for config in ts_configs:
    model = LGBMRegressor(
        n_estimators=200,
        learning_rate=0.05,
        max_depth=config['max_depth'],
        min_data_in_leaf=config['min_data_in_leaf'],
        lambda_l1=config['lambda_l1'],
        lambda_l2=config['lambda_l2'],
        subsample=0.8,
        colsample_bytree=0.8,
        random_state=42,
        verbose=-1
    )

    model.fit(X_train, y_train)

    y_train_pred = model.predict(X_train)
    y_test_pred = model.predict(X_test)

    train_rmse = np.sqrt(mean_squared_error(y_train, y_train_pred))
    test_rmse = np.sqrt(mean_squared_error(y_test, y_test_pred))

    # Извлекаем структуру деревьев
    tree_df = model.booster_.trees_to_dataframe()
    leaves_per_tree = (
        tree_df[tree_df['split_feature'].isna()]
        .groupby('tree_index')
        .size()
    )
    avg_leaves = leaves_per_tree.mean()

    results.append({
        'Configuration': config['name'],
        'Min data in leaf': config['min_data_in_leaf'],
        'Max depth': config['max_depth'],
        'Train RMSE': train_rmse,
        'Test RMSE': test_rmse,
        'Overfit ratio': train_rmse / test_rmse,
        'Avg leaves': avg_leaves
    })

results_df = pd.DataFrame(results)
print(results_df.to_string(index=False))

print("\nВыводы:")
best_config = results_df.loc[results_df['Test RMSE'].idxmin()]
print(f"Лучшая конфигурация: {best_config['Configuration']}")
print(f"Test RMSE: {best_config['Test RMSE']:.6f}")
print(f"Overfit ratio: {best_config['Overfit ratio']:.4f}")
      Configuration  Min data in leaf  Max depth  Train RMSE  Test RMSE  Overfit ratio  Avg leaves
            Default                20          3    0.019195   0.032785       0.585483    5.855000
Aggressive min_data                50          3    0.020990   0.032048       0.654956    5.330000
      Shallow trees                20          2    0.022346   0.032714       0.683073    3.695000
    L1+L2 on leaves                20          3    0.026157   0.031126       0.840351    6.269231
           Combined                40          2    0.026305   0.031057       0.846974    3.977273

Выводы:
Лучшая конфигурация: Combined
Test RMSE: 0.031057
Overfit ratio: 0.8470

Код тестирует пять конфигураций регуляризации ML-модели прогноза временных рядов на данных акций Alibaba:

  • Default конфигурация использует стандартные параметры LightGBM;
  • Aggressive min_data увеличивает минимум наблюдений в листе до 50 — каждое правило основано на большей статистике;
  • Shallow trees ограничивают глубину двумя уровнями — простые взаимодействия признаков;
  • L1+L2 on leaves применяет регуляризацию к весам листьев без изменения структуры дерева;
  • Combined объединяет все методы — агрессивный min_data_in_leaf, мелкие деревья и регуляризация весов;
  • Метрика Avg leaves показывает среднее количество листьев на дерево — индикатор сложности модели.
👉🏻  Регуляризация: L1 (Lasso) vs L2 (Ridge). Борьба с переобучением, отбор признаков

Результаты демонстрируют компромисс между train и test производительностью. Конфигурации с сильной регуляризацией показывают худший train RMSE, но лучший test RMSE и overfit ratio ближе к 1.0. Для продакшена выбираем конфигурацию с наименьшим test RMSE и стабильным overfit ratio, поскольку модель лучше генерализует на новые данные.

Оценка качества на временных рядах

Стандартная кросс-валидация нарушает временную структуру данных. Она не учитывает паттерны последовательностей.

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

Time-series cross-validation

Метод Walk-forward validation сохраняет хронологический порядок рядов для кросс-валидации:

  1. Модель обучается на данных до момента T;
  2. Валидируется на периоде [T, T+h];
  3. Затем окно сдвигается вперед.

Каждая итерация использует только прошлые данные для предсказания будущих — реалистичная симуляция продакшена.

В Time-series cross-validation используются 2 разных подхода к разбиению данных:

  • Expanding window наращивает обучающую выборку с каждой итерацией. Первый фолд обучается на данных [0, T₁], второй на [0, T₂], третий на [0, T₃]. Валидационное окно фиксированной длины h следует сразу за обучающими данными. Этот подход максимизирует использование данных, но замедляет обучение на больших выборках.
  • Sliding window сохраняет постоянный размер обучающей выборки. Первый фолд использует [0, T], второй [h, T+h], третий [2h, T+2h]. Этот метод адаптируется к изменениям режима рынка — старые данные забываются, модель фокусируется на актуальных паттернах. Для высокочастотных стратегий sliding window предпочтительнее.
import numpy as np
import pandas as pd
from sklearn.model_selection import TimeSeriesSplit
from lightgbm import LGBMRegressor
from sklearn.metrics import mean_squared_error, mean_absolute_error
import yfinance as yf
import matplotlib.pyplot as plt

# Загрузка данных
ticker = yf.Ticker("SAP")
data = ticker.history(period="5y", interval="1d")

if isinstance(data.columns, pd.MultiIndex):
    data.columns = data.columns.droplevel(1)

data = data[['Close', 'Volume']].dropna()
data['returns'] = data['Close'].pct_change()

# Feature engineering
for lag in range(1, 11):
    data[f'returns_lag_{lag}'] = data['returns'].shift(lag)

for window in [5, 10, 20]:
    data[f'returns_rolling_mean_{window}'] = data['returns'].rolling(window).mean()
    data[f'returns_rolling_std_{window}'] = data['returns'].rolling(window).std()

data['target'] = data['returns'].shift(-1)
data = data.dropna()

feature_cols = [col for col in data.columns if 'lag' in col or 'rolling' in col]
X = data[feature_cols]
y = data['target']

# TimeSeriesSplit для expanding window
tscv = TimeSeriesSplit(n_splits=5)

expanding_results = []

for fold_idx, (train_idx, val_idx) in enumerate(tscv.split(X)):
    X_train, X_val = X.iloc[train_idx], X.iloc[val_idx]
    y_train, y_val = y.iloc[train_idx], y.iloc[val_idx]
    
    model = LGBMRegressor(
        n_estimators=100,
        learning_rate=0.05,
        max_depth=3,
        subsample=0.8,
        colsample_bytree=0.8,
        random_state=42,
        verbose=-1
    )
    
    model.fit(X_train, y_train)
    y_pred = model.predict(X_val)
    
    rmse = np.sqrt(mean_squared_error(y_val, y_pred))
    mae = mean_absolute_error(y_val, y_pred)
    
    expanding_results.append({
        'Fold': fold_idx + 1,
        'Train size': len(X_train),
        'Val size': len(X_val),
        'RMSE': rmse,
        'MAE': mae
    })

expanding_df = pd.DataFrame(expanding_results)
print("Expanding Window Validation:")
print(expanding_df.to_string(index=False))
print(f"\nСредний RMSE: {expanding_df['RMSE'].mean():.6f}")
print(f"Std RMSE: {expanding_df['RMSE'].std():.6f}")

# Sliding window validation
window_size = 500  # Фиксированный размер обучающей выборки
val_size = 100     # Размер валидационного окна
n_folds = 5

sliding_results = []

for fold_idx in range(n_folds):
    start_idx = fold_idx * val_size
    train_end_idx = start_idx + window_size
    val_end_idx = train_end_idx + val_size
    
    if val_end_idx > len(X):
        break
    
    X_train = X.iloc[start_idx:train_end_idx]
    y_train = y.iloc[start_idx:train_end_idx]
    X_val = X.iloc[train_end_idx:val_end_idx]
    y_val = y.iloc[train_end_idx:val_end_idx]
    
    model = LGBMRegressor(
        n_estimators=100,
        learning_rate=0.05,
        max_depth=3,
        subsample=0.8,
        colsample_bytree=0.8,
        random_state=42,
        verbose=-1
    )
    
    model.fit(X_train, y_train)
    y_pred = model.predict(X_val)
    
    rmse = np.sqrt(mean_squared_error(y_val, y_pred))
    mae = mean_absolute_error(y_val, y_pred)
    
    sliding_results.append({
        'Fold': fold_idx + 1,
        'Train size': len(X_train),
        'Val size': len(X_val),
        'RMSE': rmse,
        'MAE': mae
    })

sliding_df = pd.DataFrame(sliding_results)
print("\n\nSliding Window Validation:")
print(sliding_df.to_string(index=False))
print(f"\nСредний RMSE: {sliding_df['RMSE'].mean():.6f}")
print(f"Std RMSE: {sliding_df['RMSE'].std():.6f}")

# Визуализация стабильности метрик
fig, axes = plt.subplots(1, 2, figsize=(14, 5))

# Expanding window
axes[0].plot(expanding_df['Fold'], expanding_df['RMSE'], 
             marker='o', linewidth=2, markersize=8, color='#2C3E50', label='RMSE')
axes[0].plot(expanding_df['Fold'], expanding_df['MAE'], 
             marker='s', linewidth=2, markersize=8, color='#7F8C8D', label='MAE')
axes[0].set_xlabel('Fold')
axes[0].set_ylabel('Error')
axes[0].set_title('Expanding Window: Metric Stability')
axes[0].legend()
axes[0].grid(alpha=0.3)

# Sliding window
axes[1].plot(sliding_df['Fold'], sliding_df['RMSE'], 
             marker='o', linewidth=2, markersize=8, color='#2C3E50', label='RMSE')
axes[1].plot(sliding_df['Fold'], sliding_df['MAE'], 
             marker='s', linewidth=2, markersize=8, color='#7F8C8D', label='MAE')
axes[1].set_xlabel('Fold')
axes[1].set_ylabel('Error')
axes[1].set_title('Sliding Window: Metric Stability')
axes[1].legend()
axes[1].grid(alpha=0.3)

plt.tight_layout()
plt.show()

Стабильность метрик при expanding и sliding window валидации. Левая панель показывает RMSE и MAE для expanding window — метрики стабилизируются с ростом обучающей выборки. Правая панель демонстрирует sliding window — колебания метрик отражают изменения режима рынка между периодами. Sliding window выявляет периоды, где модель проваливается, даже если средний RMSE приемлем

Рис. 2: Стабильность метрик при expanding и sliding window валидации. Левая панель показывает RMSE и MAE для expanding window — метрики стабилизируются с ростом обучающей выборки. Правая панель демонстрирует sliding window — колебания метрик отражают изменения режима рынка между периодами. Sliding window выявляет периоды, где модель проваливается, даже если средний RMSE приемлем

Expanding Window Validation:
 Fold  Train size  Val size     RMSE      MAE
    1         209       205 0.020395 0.015975
    2         414       205 0.019985 0.014418
    3         619       205 0.014190 0.010333
    4         824       205 0.015718 0.011926
    5        1029       205 0.018325 0.013105

Средний RMSE: 0.017723
Std RMSE: 0.002698


Sliding Window Validation:
 Fold  Train size  Val size     RMSE      MAE
    1         500       100 0.015581 0.012205
    2         500       100 0.014041 0.009830
    3         500       100 0.014332 0.010582
    4         500       100 0.016033 0.012301
    5         500       100 0.016046 0.012163

Средний RMSE: 0.015207
Std RMSE: 0.000955

Код реализует оба подхода к time-series кросс-валидации на котировках акций SAP.

Expanding window использует TimeSeriesSplit из sklearn — каждый фолд наращивает обучающую выборку. Train size увеличивается с каждой итерацией, val size остается постоянным.

Sliding window реализован вручную с фиксированным window_size=500 дней. Каждый фолд сдвигает окно на val_size=100 дней вперед. Этот метод тестирует модель на последовательных периодах одинаковой длины — проверка стабильности на разных режимах рынка.

Стандартное отклонение RMSE между фолдами показывает стабильность модели:

  • Низкий std (< 10% от среднего RMSE) указывает на устойчивость к изменениям данных;
  • Высокий std (> 30%) означает зависимость качества от конкретного периода — модель захватывает временные паттерны, не генерализующиеся на новые данные.

Метрики качества

В обучении бейзлайн моделей обычно используют 4 метрики качества: MAE, RMSE, MAPE, Directional Accuracy.

MAE

MAE (Mean Absolute Error) измеряет среднюю абсолютную ошибку прогноза. Для временных рядов доходностей MAE=0.01 означает среднее отклонение 1% от факта.

Метрика линейна — ошибка в 2% штрафуется вдвое сильнее, чем ошибка в 1%. MAE робастна к выбросам, подходит для данных с редкими крупными движениями.

RMSE

RMSE (Root Mean Squared Error) возводит ошибки в квадрат перед усреднением. Крупные промахи получают непропорционально большой вес — ошибка в 2% штрафуется в 4 раза сильнее, чем ошибка в 1%.

Для риск-менеджмента RMSE предпочтительнее — модель должна избегать катастрофических ошибок, даже если средняя точность ниже.

MAPE

MAPE (Mean Absolute Percentage Error) нормализует ошибку относительно фактического значения. Выражается в процентах. С этой метрикой легче сравнивать эффективность моделей на рядах с разными масштабами размаха значений.

Для цен активов MAPE неприменим напрямую — деление на доходность, близкую к нулю, дает бесконечность. Вместо этого используется symmetric MAPE: 2 × |pred — actual| / (|pred| + |actual|), избегающий деления на ноль.

Directional Accuracy

Показатель Directional Accuracy измеряет долю правильно предсказанных направлений движения. Модель корректна, если sign(pred) = sign(actual).

Для торговых стратегий Directional Accuracy >53% на дневных данных создает положительное математическое ожидание с учетом комиссий. Однако надо учитывать, что метрика не учитывает величину движения — предсказание роста на 0.1% при факте +5% считается успехом.

import numpy as np
import pandas as pd
from lightgbm import LGBMRegressor
from sklearn.metrics import mean_squared_error, mean_absolute_error
import yfinance as yf

# Функция для расчета метрик качества для временных рядов
def calculate_metrics(y_true, y_pred):
    
    mae = mean_absolute_error(y_true, y_pred)
    rmse = np.sqrt(mean_squared_error(y_true, y_pred))
    
    # Symmetric MAPE
    smape = np.mean(2 * np.abs(y_pred - y_true) / (np.abs(y_pred) + np.abs(y_true) + 1e-8)) * 100
    
    # Directional accuracy
    correct_direction = np.sign(y_pred) == np.sign(y_true)
    directional_acc = correct_direction.mean() * 100
    
    # Hit rate для значимых движений (>0.5%)
    significant_moves = np.abs(y_true) > 0.005
    if significant_moves.sum() > 0:
        hit_rate_significant = correct_direction[significant_moves].mean() * 100
    else:
        hit_rate_significant = np.nan
    
    return {
        'MAE': mae,
        'RMSE': rmse,
        'SMAPE': smape,
        'Directional Accuracy': directional_acc,
        'Hit Rate (>0.5%)': hit_rate_significant
    }

# Загрузка данных
ticker = yf.Ticker("NVDA")  #NVIDIA
data = ticker.history(period="3y", interval="1d")

if isinstance(data.columns, pd.MultiIndex):
    data.columns = data.columns.droplevel(1)

data = data[['Close', 'Volume']].dropna()
data['returns'] = data['Close'].pct_change()

# Feature engineering
for lag in range(1, 11):
    data[f'returns_lag_{lag}'] = data['returns'].shift(lag)

for window in [5, 10, 20]:
    data[f'returns_rolling_mean_{window}'] = data['returns'].rolling(window).mean()
    data[f'returns_rolling_std_{window}'] = data['returns'].rolling(window).std()

# Целевая переменная: доходность на следующий день
data['target'] = data['returns'].shift(-1)
data = data.dropna()

feature_cols = [col for col in data.columns if 'lag' in col or 'rolling' in col]
X = data[feature_cols]
y = data['target']

# Train/test split
train_size = int(len(data) * 0.8)
X_train, X_test = X[:train_size], X[train_size:]
y_train, y_test = y[:train_size], y[train_size:]

# Обучение модели
model = LGBMRegressor(
    n_estimators=250,
    learning_rate=0.05,
    max_depth=5,
    subsample=0.8,
    colsample_bytree=0.8,
    random_state=24,
    verbose=-1
)

model.fit(X_train, y_train)

# Предсказания
y_train_pred = model.predict(X_train)
y_test_pred = model.predict(X_test)

# Расчет метрик
train_metrics = calculate_metrics(y_train, y_train_pred)
test_metrics = calculate_metrics(y_test, y_test_pred)

print("Метрики на обучающей выборке:")
for metric, value in train_metrics.items():
    print(f"  {metric:25}: {value:.4f}")

print("\nМетрики на тестовой выборке:")
for metric, value in test_metrics.items():
    print(f"  {metric:25}: {value:.4f}")

# Анализ ошибок по квантилям
test_errors = np.abs(y_test - y_test_pred)
quantiles = [0.5, 0.75, 0.9, 0.95, 0.99]

print("\nРаспределение ошибок (квантили):")
for q in quantiles:
    print(f"  {int(q*100)}%: {np.quantile(test_errors, q):.6f}")

# Производительность на разных горизонтах доходности
print("\nDirectional accuracy по величине движения:")
for threshold in [0.001, 0.005, 0.01, 0.02, 0.03]:
    mask = np.abs(y_test) > threshold
    if mask.sum() > 0:
        acc = (np.sign(y_test_pred[mask]) == np.sign(y_test[mask])).mean() * 100
        print(f"  >{threshold*100:.1f}%: {acc:.2f}% (n={mask.sum()})")
Метрики на обучающей выборке:
  MAE                      : 0.0096
  RMSE                     : 0.0134
  SMAPE                    : 69.3480
  Directional Accuracy     : 89.0411
  Hit Rate (>0.5%)         : 94.4223

Метрики на тестовой выборке:
  MAE                      : 0.0201
  RMSE                     : 0.0294
  SMAPE                    : 136.8930
  Directional Accuracy     : 58.9041
  Hit Rate (>0.5%)         : 58.9286

Распределение ошибок (квантили):
  50%: 0.014875
  75%: 0.025482
  90%: 0.040927
  95%: 0.047746
  99%: 0.087581

Directional accuracy по величине движения:
  >0.1%: 58.87% (n=141)
  >0.5%: 58.93% (n=112)
  >1.0%: 64.20% (n=81)
  >2.0%: 63.46% (n=52)
  >3.0%: 65.52% (n=29)

Код вычисляет пять метрик на котировках акций Nvidia. MAE и RMSE показывают абсолютную точность прогноза в единицах доходности. SMAPE нормализует ошибку, позволяя сравнивать модели на разных инструментах. Directional accuracy оценивает способность предсказывать направление — ключевая метрика для торговых систем.

👉🏻  Анализ фьючерса на Brent с помощью Pandas, Sklearn, Hmmlearn

Hit rate для значимых движений (>0.5%) фильтрует дни с минимальной волатильностью. Предсказание направления при движении в 0.1% не несет практической ценности — транзакционные издержки съедают прибыль. Фокус на движениях >0.5% показывает реальную применимость модели для трейдинга.

Анализ квантилей ошибок выявляет вероятность хвостовых рисков распределений (tail risk):

  1. 95% квантиль показывает максимальную ошибку для 95% предсказаний;
  2. Если 99% квантиль значительно выше 95%, модель периодически дает катастрофические промахи;
  3. Для автоматизированных стратегий важна стабильность — лучше модель с MAE=0.012 и узким распределением ошибок, чем с MAE=0.010 и редкими выбросами в 5%.

Directional accuracy по величине движения демонстрирует участки, где модель работает лучше. Как правило, точность растет на крупных движениях (>2%) — сильные сигналы легче предсказывать. Падение точности на малых движениях (<0.5%) ожидаемо — шум доминирует над сигналом.

Sharpe ratio адаптируется как метрика качества модели через конструирование синтетической стратегии. Позиция определяется предсказанием:

long при pred > 0, short при pred < 0.

Доходность стратегии вычисляется как pred × actual — корректное предсказание направления дает положительный вклад. Sharpe ratio этой синтетической equity curve оценивает качество модели с поправками на риск.

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

# Загрузка данных
ticker = yf.Ticker("BA") #Boeing
data = ticker.history(period="3y", interval="1d")

if isinstance(data.columns, pd.MultiIndex):
    data.columns = data.columns.droplevel(1)

data = data[['Close']].dropna()
data['returns'] = data['Close'].pct_change()

# Feature engineering
for lag in range(1, 11):
    data[f'returns_lag_{lag}'] = data['returns'].shift(lag)

for window in [5, 10, 20]:
    data[f'returns_rolling_mean_{window}'] = data['returns'].rolling(window).mean()
    data[f'returns_rolling_std_{window}'] = data['returns'].rolling(window).std()

data['target'] = data['returns'].shift(-1)
data = data.dropna()

feature_cols = [col for col in data.columns if 'lag' in col or 'rolling' in col]
X = data[feature_cols]
y = data['target']

# Train/test split
train_size = int(len(data) * 0.8)
X_train, X_test = X[:train_size], X[train_size:]
y_train, y_test = y[:train_size], y[train_size:]

# Обучение модели
model = LGBMRegressor(
    n_estimators=100,
    learning_rate=0.05,
    max_depth=3,
    subsample=0.8,
    colsample_bytree=0.8,
    random_state=42,
    verbose=-1
)

model.fit(X_train, y_train)
y_test_pred = model.predict(X_test)

# Конструирование синтетической стратегии
strategy_returns = np.sign(y_test_pred) * y_test
cumulative_returns = (1 + strategy_returns).cumprod()

# Buy & Hold benchmark
buy_hold_returns = y_test
buy_hold_cumulative = (1 + buy_hold_returns).cumprod()

# Расчет Sharpe ratio (252 торговых дня)
strategy_sharpe = strategy_returns.mean() / strategy_returns.std() * np.sqrt(252)
buy_hold_sharpe = buy_hold_returns.mean() / buy_hold_returns.std() * np.sqrt(252)

# Максимальная просадка
def max_drawdown(returns):
    cumulative = (1 + returns).cumprod()
    running_max = cumulative.expanding().max()
    drawdown = (cumulative - running_max) / running_max
    return drawdown.min()

strategy_mdd = max_drawdown(strategy_returns)
buy_hold_mdd = max_drawdown(buy_hold_returns)

print("Метрики стратегии на основе модели:")
print(f"  Sharpe Ratio: {strategy_sharpe:.4f}")
print(f"  Max Drawdown: {strategy_mdd:.4%}")
print(f"  Total Return: {(cumulative_returns.iloc[-1] - 1):.4%}")
print(f"  Win Rate: {(strategy_returns > 0).mean():.4%}")

print("\nBuy & Hold benchmark:")
print(f"  Sharpe Ratio: {buy_hold_sharpe:.4f}")
print(f"  Max Drawdown: {buy_hold_mdd:.4%}")
print(f"  Total Return: {(buy_hold_cumulative.iloc[-1] - 1):.4%}")

# Визуализация equity curve
fig, axes = plt.subplots(2, 1, figsize=(12, 8))

# Cumulative returns
axes[0].plot(cumulative_returns.index, cumulative_returns.values, 
             linewidth=2, color='#2C3E50', label='Model Strategy')
axes[0].plot(buy_hold_cumulative.index, buy_hold_cumulative.values, 
             linewidth=2, color='#7F8C8D', label='Buy & Hold', alpha=0.7)
axes[0].set_ylabel('Cumulative Return')
axes[0].set_title('Strategy Performance: Model vs Buy & Hold')
axes[0].legend()
axes[0].grid(alpha=0.3)

# Drawdown
strategy_cumulative_full = (1 + strategy_returns).cumprod()
strategy_running_max = strategy_cumulative_full.expanding().max()
strategy_drawdown = (strategy_cumulative_full - strategy_running_max) / strategy_running_max

axes[1].fill_between(strategy_drawdown.index, strategy_drawdown.values * 100, 0, 
                      color='#E74C3C', alpha=0.3)
axes[1].plot(strategy_drawdown.index, strategy_drawdown.values * 100, 
             linewidth=1, color='#C0392B')
axes[1].set_ylabel('Drawdown (%)')
axes[1].set_xlabel('Date')
axes[1].set_title('Strategy Drawdown')
axes[1].grid(alpha=0.3)

plt.tight_layout()
plt.show()

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

Рис. 3: Производительность стратегии на основе модели против buy & hold. Верхняя панель показывает кумулятивную доходность — стратегия модели превосходит пассивное владение в периоды высокой предсказуемости. Нижняя панель демонстрирует просадки стратегии — максимальная просадка остается в контролируемых пределах. Периоды глубоких просадок совпадают с режимными сдвигами рынка, когда исторические паттерны перестают работать

Метрики стратегии на основе модели:
  Sharpe Ratio: 3.8074
  Max Drawdown: -14.7837%
  Total Return: 121.7934%
  Win Rate: 60.9589%

Buy & Hold benchmark:
  Sharpe Ratio: 1.0624
  Max Drawdown: -25.1931%
  Total Return: 21.7338%

Код создает синтетическую торговую стратегию на котировках акций Boeing за последние 3 года. Позиция определяется знаком предсказания: положительный прогноз означает long, отрицательный — short.

В отчете этой стратегии мы можем наблюдать следующие показатели:

  • Strategy_returns вычисляется как произведение предсказанного и фактического направления — корректные прогнозы дают положительную доходность.
  • Sharpe ratio стратегии сравнивается с бенчмарком стратегии buy & hold. Значение выше 1.0 считается хорошим для дневных стратегий, выше 2.0 — отличным. Если Sharpe модели ниже buy & hold, стратегия не имеет смысла — проще держать актив.
  • Win rate показывает долю прибыльных дней. Чем выше Win rate, тем лучше. Однако важно помнить, что данная метрика обманчива — 45% win rate при правильном управлении размером позиции дает положительную прибыль.
  • Максимальная просадка (Maximum drawdown, MDD) измеряет наихудшее падение от пика за период. Для автоматизированных систем MDD определяет требования к капиталу — стратегия с MDD -25% требует запас ликвидности для того, чтобы «пересидеть» период просадки. Также важно помнить, что просадки выше -40% и -50% неприемлемы для большинства инвесторов, даже при высоком Sharpe ratio.
👉🏻  Теория вероятностей и биржевая торговля

Проверка на переобучение (overfitting)

Разделение временного ряда на Train / Validation / Test выборки изолирует три этапа разработки модели:

  • Train используется для обучения;
  • Validation для подбора гиперпараметров;
  • Test для финальной оценки.

Модель не должна видеть данные из Test выборки до завершения всех экспериментов — иначе неявная оптимизация под эту выборку приводит к переобучению на уровне процесса разработки.

Для временных рядов разделение сохраняет хронологический порядок: train [0, 0.7], validation [0.7, 0.85], test [0.85, 1.0]. Пропорции зависят от объема данных — для 5 лет дневных данных (1250 наблюдений) test выборка в 15% дает 187 дней, достаточно для статистически значимой оценки.

Стабильность метрик на разных периодах выявляет переобучение на конкретных режимах рынка. Модель тестируется на последовательных 3-месячных окнах внутри test выборки. Если RMSE варьируется от 0.008 до 0.025, модель захватывает временные паттерны без генерализации. Стабильный RMSE в диапазоне 0.012-0.016, напротив, указывает на робастность обученной модели.

Важно не забывать, что период данных вне выборки (Out-of-sample period) должен содержать разные режимы рынка: рост, падение, боковое движение, высокую и низкую волатильность. Модель, обученная только на бычьем рынке 2020-2021, ожидаемо провалится в коррекции 2022 года. Включение в train данных разных циклов повышает устойчиовсть ML-модели, но увеличивает риск устаревания паттернов.

import numpy as np
import pandas as pd
from lightgbm import LGBMRegressor
from sklearn.metrics import mean_squared_error
import yfinance as yf
import matplotlib.pyplot as plt

# Загрузка данных
ticker = yf.Ticker("GOOGL")
data = ticker.history(period="5y", interval="1d")

if isinstance(data.columns, pd.MultiIndex):
    data.columns = data.columns.droplevel(1)

data = data[['Close', 'Volume']].dropna()
data['returns'] = data['Close'].pct_change()

# Feature engineering
for lag in range(1, 11):
    data[f'returns_lag_{lag}'] = data['returns'].shift(lag)

for window in [5, 10, 20]:
    data[f'returns_rolling_mean_{window}'] = data['returns'].rolling(window).mean()
    data[f'returns_rolling_std_{window}'] = data['returns'].rolling(window).std()

data['target'] = data['returns'].shift(-1)
data = data.dropna()

feature_cols = [col for col in data.columns if 'lag' in col or 'rolling' in col]
X = data[feature_cols]
y = data['target']

# Train/validation/test split
train_size = int(len(data) * 0.7)
val_size = int(len(data) * 0.15)

X_train = X[:train_size]
y_train = y[:train_size]
X_val = X[train_size:train_size + val_size]
y_val = y[train_size:train_size + val_size]
X_test = X[train_size + val_size:]
y_test = y[train_size + val_size:]

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

# Обучение модели
model = LGBMRegressor(
    n_estimators=250,
    learning_rate=0.01,
    max_depth=5,
    subsample=0.8,
    colsample_bytree=0.8,
    random_state=42,
    verbose=-1
)

model.fit(X_train, y_train)

# Предсказания на всех выборках
y_train_pred = model.predict(X_train)
y_val_pred = model.predict(X_val)
y_test_pred = model.predict(X_test)

# RMSE на каждой выборке
train_rmse = np.sqrt(mean_squared_error(y_train, y_train_pred))
val_rmse = np.sqrt(mean_squared_error(y_val, y_val_pred))
test_rmse = np.sqrt(mean_squared_error(y_test, y_test_pred))

print(f"\nRMSE:")
print(f"  Train: {train_rmse:.6f}")
print(f"  Validation: {val_rmse:.6f}")
print(f"  Test: {test_rmse:.6f}")

overfit_train_test = train_rmse / test_rmse
print(f"\nOverfit ratio (Train/Test): {overfit_train_test:.4f}")

# Тест на стабильность: разбиение test на подпериоды
test_dates = X_test.index
n_subperiods = 4
subperiod_size = len(X_test) // n_subperiods

subperiod_results = []

for i in range(n_subperiods):
    start_idx = i * subperiod_size
    end_idx = (i + 1) * subperiod_size if i < n_subperiods - 1 else len(X_test)
    
    X_sub = X_test.iloc[start_idx:end_idx]
    y_sub = y_test.iloc[start_idx:end_idx]
    y_sub_pred = model.predict(X_sub)
    
    rmse_sub = np.sqrt(mean_squared_error(y_sub, y_sub_pred))
    directional_acc = (np.sign(y_sub_pred) == np.sign(y_sub)).mean() * 100
    
    subperiod_results.append({
        'Period': f"{test_dates[start_idx].strftime('%Y-%m-%d')} to {test_dates[min(end_idx-1, len(test_dates)-1)].strftime('%Y-%m-%d')}",
        'RMSE': rmse_sub,
        'Dir. Acc.': directional_acc,
        'n': len(X_sub)
    })

subperiod_df = pd.DataFrame(subperiod_results)
print("\nСтабильность на подпериодах test выборки:")
print(subperiod_df.to_string(index=False))

rmse_std = subperiod_df['RMSE'].std()
rmse_mean = subperiod_df['RMSE'].mean()
print(f"\nКоэффициент вариации RMSE: {rmse_std / rmse_mean:.4f}")

# Визуализация ошибок по времени
fig, axes = plt.subplots(2, 1, figsize=(14, 8))

# Предсказания vs факт на test
axes[0].scatter(range(len(y_test)), y_test.values, alpha=0.5, s=20, color='#2C3E50', label='Actual')
axes[0].scatter(range(len(y_test)), y_test_pred, alpha=0.5, s=20, color='#E74C3C', label='Predicted')
axes[0].set_ylabel('Returns')
axes[0].set_title('Test Set: Actual vs Predicted Returns')
axes[0].legend()
axes[0].grid(alpha=0.3)

# Rolling RMSE на test выборке
window_rmse = 50
rolling_rmse = []

for i in range(window_rmse, len(y_test)):
    window_actual = y_test.iloc[i-window_rmse:i]
    window_pred = y_test_pred[i-window_rmse:i]
    rmse_window = np.sqrt(mean_squared_error(window_actual, window_pred))
    rolling_rmse.append(rmse_window)

axes[1].plot(range(window_rmse, len(y_test)), rolling_rmse, 
             linewidth=2, color='#3498DB')
axes[1].axhline(y=test_rmse, color='#E74C3C', linestyle='--', 
                linewidth=2, label=f'Overall Test RMSE: {test_rmse:.6f}')
axes[1].set_ylabel('Rolling RMSE')
axes[1].set_xlabel('Test Sample Index')
axes[1].set_title(f'Rolling RMSE (window={window_rmse})')
axes[1].legend()
axes[1].grid(alpha=0.3)

plt.tight_layout()
plt.show()

Анализ переобучения на test выборке. Верхняя панель показывает рассеяние фактических и предсказанных доходностей — предсказания группируются вокруг нуля с меньшей дисперсией, чем факт. Модель консервативна, недооценивает экстремальные движения. Нижняя панель демонстрирует rolling RMSE на окне 50 дней — всплески ошибки совпадают с периодами повышенной волатильности. Стабильность rolling RMSE вокруг overall test RMSE подтверждает отсутствие критической деградации качества

Рис. 4: Анализ переобучения на test выборке. Верхняя панель показывает рассеяние фактических и предсказанных доходностей — предсказания группируются вокруг нуля с меньшей дисперсией, чем факт. Модель консервативна, недооценивает экстремальные движения. Нижняя панель демонстрирует rolling RMSE на окне 50 дней — всплески ошибки совпадают с периодами повышенной волатильности. Стабильность rolling RMSE вокруг overall test RMSE подтверждает отсутствие критической деградации качества

Train size: 863 (69.9%)
Validation size: 185 (15.0%)
Test size: 186 (15.1%)

RMSE:
  Train: 0.016973
  Validation: 0.016590
  Test: 0.020967

Overfit ratio (Train/Test): 0.8095

Стабильность на подпериодах test выборки:
                  Period     RMSE  Dir. Acc.  n
2025-01-23 to 2025-03-28 0.023245  47.826087 46
2025-03-31 to 2025-06-04 0.025336  47.826087 46
2025-06-05 to 2025-08-11 0.014821  56.521739 46
2025-08-12 to 2025-10-17 0.018976  54.166667 48

Коэффициент вариации RMSE: 0.2268

Код выполняет комплексную проверку на переобучение модели прогнозирования котировок акций Google:

  1. Разделение временного ряда на выборки Train / Validation / Test в пропорции 70/15/15 обеспечивает достаточный объем для каждого этапа. Валидационная выборка используется для мониторинга качества в процессе разработки, а тестовая остается нетронутой до финальной оценки качества ML-модели.
  2. Показатель Overfit ratio (Train RMSE / Test RMSE) количественно оценивает переобучение. Значение близкое к 1.0 идеально, 0.9-0.95 приемлемо для финансовых данных, ниже 0.8 указывает на серьезное переобучение. Если val_rmse близок к test_rmse, но оба значительно хуже train_rmse, модель переобучилась на обучающей выборке.
  3. Разбиение test на 4 подпериода проверяет стабильность метрик качества во времени.

Интерпретация других метрик следующая. Коэффициент вариации RMSE (std / mean) ниже 0.15 означает стабильную производительность. Значение выше 0.30 указывает на зависимость от режима рынка — модель работает в одних условиях и проваливается в других.

Заключение

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

Простые модели, такие как линейная регрессия с правильными признаками или ETS/Prophet для рядов с выраженной сезонностью, часто показывают стабильные результаты и могут служить надежным ориентиром для оценки улучшений. В то же время современные методы, такие как градиентный бустинг и CatBoost, демонстрируют высокую эффективность на средних и больших выборках, особенно когда данные разнородны или содержат категориальные признаки.

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

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