Бейзлайн (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 указывает на коррекцию внутри восходящего тренда.
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

Рис. 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 показывает среднее количество листьев на дерево — индикатор сложности модели.
Результаты демонстрируют компромисс между train и test производительностью. Конфигурации с сильной регуляризацией показывают худший train RMSE, но лучший test RMSE и overfit ratio ближе к 1.0. Для продакшена выбираем конфигурацию с наименьшим test RMSE и стабильным overfit ratio, поскольку модель лучше генерализует на новые данные.
Оценка качества на временных рядах
Стандартная кросс-валидация нарушает временную структуру данных. Она не учитывает паттерны последовательностей.
Так, к примеру, метод Random shuffle перемешивает наблюдения, создавая утечку информации из будущего в прошлое. В результате модель обучается на данных уже после валидационного периода и демонстрирует завышенное качество, что ведет к провалу метрик в продакшене.
Time-series cross-validation
Метод Walk-forward validation сохраняет хронологический порядок рядов для кросс-валидации:
- Модель обучается на данных до момента T;
- Валидируется на периоде [T, T+h];
- Затем окно сдвигается вперед.
Каждая итерация использует только прошлые данные для предсказания будущих — реалистичная симуляция продакшена.
В 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()

Рис. 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 оценивает способность предсказывать направление — ключевая метрика для торговых систем.
Hit rate для значимых движений (>0.5%) фильтрует дни с минимальной волатильностью. Предсказание направления при движении в 0.1% не несет практической ценности — транзакционные издержки съедают прибыль. Фокус на движениях >0.5% показывает реальную применимость модели для трейдинга.
Анализ квантилей ошибок выявляет вероятность хвостовых рисков распределений (tail risk):
- 95% квантиль показывает максимальную ошибку для 95% предсказаний;
- Если 99% квантиль значительно выше 95%, модель периодически дает катастрофические промахи;
- Для автоматизированных стратегий важна стабильность — лучше модель с 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()

Рис. 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()

Рис. 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:
- Разделение временного ряда на выборки Train / Validation / Test в пропорции 70/15/15 обеспечивает достаточный объем для каждого этапа. Валидационная выборка используется для мониторинга качества в процессе разработки, а тестовая остается нетронутой до финальной оценки качества ML-модели.
- Показатель Overfit ratio (Train RMSE / Test RMSE) количественно оценивает переобучение. Значение близкое к 1.0 идеально, 0.9-0.95 приемлемо для финансовых данных, ниже 0.8 указывает на серьезное переобучение. Если val_rmse близок к test_rmse, но оба значительно хуже train_rmse, модель переобучилась на обучающей выборке.
- Разбиение test на 4 подпериода проверяет стабильность метрик качества во времени.
Интерпретация других метрик следующая. Коэффициент вариации RMSE (std / mean) ниже 0.15 означает стабильную производительность. Значение выше 0.30 указывает на зависимость от режима рынка — модель работает в одних условиях и проваливается в других.
Заключение
Baseline модели определяют точку отсчета для любой системы прогнозирования временных рядов. Они позволяют оценить, стоит ли использовать более сложные алгоритмы, и помогают избежать неоправданной сложности в проектах.
Простые модели, такие как линейная регрессия с правильными признаками или ETS/Prophet для рядов с выраженной сезонностью, часто показывают стабильные результаты и могут служить надежным ориентиром для оценки улучшений. В то же время современные методы, такие как градиентный бустинг и CatBoost, демонстрируют высокую эффективность на средних и больших выборках, особенно когда данные разнородны или содержат категориальные признаки.
Правильная настройка регуляризации и подбор гиперпараметров позволяют моделям избегать переобучения и извлекать сложные нелинейные зависимости, которые недоступны для простых моделей.
Таким образом, использование бейзлайн моделей в сочетании с более сложными алгоритмами позволяет строить прогнозы, которые одновременно точны, интерпретируемы и устойчивы к шуму, что особенно важно для финансовых временных рядов и бизнес-метрик. Такие бейзлайн модели могут стать не только инструментом оценки качества, но и отправной точкой для разработки надежной системы прогнозирования.