XGBoost for Return Direction: Class Imbalance and Decision Thresholds
Обучите классификатор предсказывать, превысит ли доходность следующего часа +0,5%, и вы не построили сбалансированную двоичную задачу. Во время спокойных криптографических режимов 70–80% баров падают ниже порогового значения, поэтому модель может достичь точности 75%, никогда не предсказывая ни одного положительного результата. Точность здесь ни к чему. Так же как и граница решения по умолчанию 0,5, которую никто не выбрал сознательно — это значение predict() случается использовать.
Есть три стандартных ответа: переоценить потери с помощью scale_pos_weight, замените потерю очаговой потерей или оставьте потерю в покое и переместите порог принятия решения постфактум. Обычно их представляют как взаимозаменяемые. Это не так. Они производят разные компромиссы между точностью и полнотой и, что более важно, разные PnL после учета затрат, поскольку в трейдинге точность гораздо важнее, чем полнота — пропущенная сделка ничего не стоит, а плохая требует спреда плюс комиссии.
В этой статье все три измеряются на одних и тех же очищенных почасовых данных BTC и ETH, а затем рассматриваются две вытекающие из этого вещи: почему min_child_weight — это порог Гессиана, а не количество строк (именно поэтому "правильное" значение смещается при повторном взвешивании потерь), и где на практике различаются XGBoost, LightGBM и CatBoost.
| Подход | Точность | Напомним | Сделки | Пост-затраты PnL | Сдутый Шарп |
|---|---|---|---|---|---|
| Базовый уровень (без коррекции, thr=0,5) | — | — | — | — | — |
scale_pos_weight = n_neg/n_pos |
— | — | — | — | — |
| Потеря фокуса (γ=2, α=0,25) | — | — | — | — | — |
| Пороговая оптимизация (минимальная точность 0,55) | — | — | — | — | — |
Те же складки, те же функции, одна и та же модель стоимости для каждого ряда. Шарп занижен по количеству опробованных конфигураций, согласно выкаченному Шарпу и многократному тестированию; затраты следуют за моделями проскальзывания и затрат.
Обработка дисбаланса классов при прогнозировании возврата

Подход 1: Scale_pos_weight
Самый простой метод. Набор scale_pos_weight = n_negative / n_positive:
n_pos = y_train.sum()
n_neg = len(y_train) - n_pos
scale_pos_weight = n_neg / n_pos # e.g., 3.0 if 75% negative
model = xgb.XGBClassifier(
scale_pos_weight=scale_pos_weight,
...
)
Это масштабирует градиент для образцов положительного класса, сообщая модели, что неправильная классификация положительного примера является ошибкой. раз хуже, чем ошибочная классификация отрицательного.
Побочный эффект легко не заметить: он также масштабирует гессианцы. С min_child_weight является порогом суммы гессианов в листе (см. ниже), повторное взвешивание потери незаметно меняет степень агрессивности разделения дерева. А min_child_weight настроен на scale_pos_weight=1 больше не означает одно и то же scale_pos_weight=3. Перенастройте оба вместе, а не последовательно.
Второй побочный эффект: выходные вероятности больше не калибруются. predict_proba возвращает что-то монотонное с истинной вероятностью, но не равное ей, что делает любое определение размера позиции в дальнейшем, основанное на этих числах, неправильным.
Подход 2: потеря фокуса
Потеря фокуса снижает вес простых примеров независимо от класса и концентрирует обучение на сложных, неоднозначных образцах. Это привлекательная основа для прогнозирования доходности, где граница между баром +0,5% и баром +0,4% в основном представляет собой шум, хотя "сосредоточиться на неоднозначных случаях" и "сосредоточиться на необучаемых случаях" — одна и та же инструкция, когда метки настолько зашумлены; именно поэтому нужно измерять, а не предполагать.
где - прогнозируемая вероятность для истинного класса, балансирует веса классов и (обычно 1–3) определяет, насколько сильно понижаются веса простых примеров.
def focal_loss_objective(y_true, y_pred, gamma=2.0, alpha=0.25):
"""
Custom focal loss for XGBoost. Returns gradient and hessian.
"""
p = 1.0 / (1.0 + np.exp(-y_pred)) # sigmoid
g1 = alpha * y_true * (1 - p)**gamma * (gamma * p * np.log(p + 1e-9) + p - 1)
g2 = (1 - alpha) * (1 - y_true) * p**gamma * (
-gamma * (1 - p) * np.log(1 - p + 1e-9) - p
)
grad = -(g1 + g2)
hess = np.maximum(grad * (1 - grad), 1e-6)
return grad, hess
model = xgb.XGBClassifier(objective=focal_loss_objective, ...)
Пользовательским целям необходим градиент и положительный гессиан, чтобы формула веса листа стала ступенькой спуска. Точная вторая производная потери фокуса не везде положительна, поэтому реализации используют суррогат, но суррогат, отклоняемый на масштабный коэффициент, меняет размеры шага, а через сумму Гессиана меняет то, что min_child_weight чернослив. Только сравнительная таблица показывает, стоит ли это чего-нибудь на практике.
Подход 3: Оптимизация порога
Оставьте потери в покое, обучите откалиброванную модель и переместите порог принятия решения в наборе проверки:
from sklearn.metrics import precision_recall_curve
def optimize_threshold(y_true, y_proba, min_precision=0.55):
"""
Find the threshold maximizing F1 subject to a minimum precision.
"""
precisions, recalls, thresholds = precision_recall_curve(y_true, y_proba)
f1_scores = 2 * (precisions * recalls) / (precisions + recalls + 1e-9)
valid = precisions[:-1] >= min_precision
if not valid.any():
return 0.5 # fallback
best_idx = np.argmax(f1_scores[:-1] * valid)
return thresholds[best_idx]
Порог точности — вот что важно для торговли. Максимизация только F1 меняет точность на полноту, что с точки зрения PnL означает больше предельных сделок, каждая из которых оплачивает спред. Ограничение точности и максимизация полноты в его рамках соответствуют принципу "торгуй меньше, но будь прав, когда торгуешь".
Два правила сохраняют это честным: установите порог для данных проверки, который находится в хронологическом порядке после окна обучения, и переустанавливайте его для каждого раза. Порог, выбранный один раз для всей выборки, представляет собой прогнозируемую утечку, описанную в таксономии прогнозного смещения. Повторная подгонка также дает бесплатную диагностику: если оптимальный порог скачет по сгибам, калибровка нестабильна и порог представляет собой подгоночный шум.
Почему min_child_weight — это сумма Гессе, а не количество строк

Повышение градиента создает аддитивный ансамбль. На шаге он добавляет дерево минимизация регуляризованной цели, которую XGBoost аппроксимирует разложением Тейлора второго порядка:
с , и
где количество листьев, вес листа, штраф за лист, и термин L2. Решение оптимального веса листа дает .
Сумма Гессе находится в знаменателе, и в этом вся суть. min_child_weight пороговые значения эта сумма, а не количество строк в листе. В случае потери журнала, , который является крупнейшим в и падает до нуля по мере того, как прогнозы приближаются к достоверности. Таким образом, лист, полный уверенно классифицированных столбцов, имеет крошечную сумму гессе и его обрезают, в то время как лист, содержащий несколько действительно неоднозначных столбцов, может выжить.
Для шумных финансовых компаний именно такое поведение и желательно, и оно объясняет практические последствия:
- Повышение
min_child_weightлистья чернослива, основанные на нескольких неопределенных наблюдениях — именно тех, которые, скорее всего, являются подходящим шумом. Это более острый инструмент, чемmin_child_samples-стиль подсчета строк. - Все, что масштабирует потери, масштабирует гессенцы.
scale_pos_weight, пользовательские цели и веса выборки — все это меняет эффективнуюmin_child_weightхотя введенный вами номер не изменился. - По мере увеличения доходов и повышения точности прогнозов гессианцы сокращаются во всем мире, поэтому фиксированная
min_child_weightв более поздних раундах сокращается более агрессивно. Это встроенный эффект отжига, и именно поэтому более низкая скорость обучения с большим количеством деревьев ведет себя иначе, чем меньшее количество более крупных шагов.
LightGBM's min_child_samples — это количество строк, то есть совершенно другой параметр с похожим именем. Его гессенский аналог min_sum_hessian_in_leaf. Перенос конфигурации между двумя библиотеками путем сопоставления имен — распространенный способ случайно изменить модель.
XGBoost против LightGBM против CatBoost: инженерные различия

Все три реализуют деревья решений с градиентным усилением. Различия заключаются в построении дерева, и именно они на самом деле проявляются во времени обучения и результатах вне выборки.
XGBoost растет по уровням (в ширину): все листья на заданной глубине разделяются, прежде чем идти глубже. Это производит сбалансированные деревья, делает max_depth значимый контроль сложности, и его более предсказуемо настраивать. Это также напрасная работа по расщеплению листьев, у которых осталось мало потерь.
LightGBM растет по листьям: он разделяет тот лист, который обеспечивает наибольшее снижение потерь, где бы этот лист ни находился. Меньшее количество разделений приводит к таким же потерям при обучении, но деревья становятся глубокими и несбалансированными, поэтому max_depth перестает быть правой ручкой управления — num_leaves является. Еще два трюка увеличивают его скорость:
- GOSS (Односторонняя выборка на основе градиента) сохраняет все экземпляры с большим градиентом и случайным образом выбирает подвыборку из экземпляров с малым градиентом, повышая вес оставшихся в живых, чтобы сохранить несмещенную оценку градиента. В финансовых данных случаи с большим градиентом — это те столбцы, которые модель в настоящее время допускает неправильно, — именно здесь живет шум меток, поэтому GOSS концентрирует выборку именно там, где шум наихудший. Стоит сверяться с простой подвыборкой, а не предполагать.
- EFB (объединение эксклюзивных функций) объединяет взаимоисключающие разреженные функции в единый элемент пространства ячеек. Эффективен для горячих кодировок; почти бесполезен для плотных непрерывных функций, которые составляют большую часть матрицы технических функций.
CatBoost выращивает симметричные (незаметные) деревья: каждый узел на заданной глубине использует одно и то же условие разделения. Это сильный регуляризатор, который делает вывод очень быстро — дерево становится индексным поиском — за счет выразительности каждого дерева. Два его отличительных механизма:
- Упорядоченное повышение. При стандартном повышении остатки для выборки вычисляются с использованием модели, обученной на этой выборке, что приводит к смещению остатка — "сдвигу прогноза". CatBoost оценивает остатки для каждой выборки, используя модель, подобранную только для предшествующих ей выборок в случайной перестановке. Структура естественно подходит для временных рядов и особенно важна, когда данных мало.
- Упорядоченная целевая статистика для категориального кодирования, которая вычисляет целевую статистику только на основе предыдущих выборок, избегая целевой утечки, которую вызывает простое среднее кодирование. Это честная причина использовать CatBoost, когда набор функций содержит метки обмена, уровня активов или режима.
| Недвижимость | XGBoost | ЛайтГБМ | CatBoost |
|---|---|---|---|
| Рост дерева | По уровням | Листовой | Симметричный (не обращающий внимания) |
| Ручка сложности | max_depth |
num_leaves |
depth |
| Охранник размером с лист | min_child_weight (Гессен) |
min_child_samples (строки) |
min_data_in_leaf (строки) |
| Категориальные особенности | Ручное кодирование | Базовая поддержка | Родной, заказанный TS |
| Регуляризация | L1/L2 + гамма | L1/L2 + количество_листов | L2 + случайная сила |
| Таможенные потери | Гибкий | Гибкий | Несколько ограничен |
| Время поезда, этот набор данных | — | — | |
| Потеря журнала OOS, те же складки | — | — |
Качественные строки — это библиотечные факты. Последние две строки — единственные, которые отвечают на вопрос "что мне использовать", и они должны быть получены на собственных данных: различия между хорошо настроенными реализациями обычно достаточно малы, чтобы решающим фактором стала форма набора данных.
Переключение библиотек — это в основном переименование. Одна обучающая функция с перечисленными, а не утроенными параметрами, которые различаются:
import xgboost as xgb
def train_xgb_model(X_train, y_train, X_val, y_val, class_weight_ratio=1.0):
"""Train XGBoost classifier for return direction prediction."""
model = xgb.XGBClassifier(
n_estimators=2000,
max_depth=5,
learning_rate=0.01,
subsample=0.7,
colsample_bytree=0.7,
min_child_weight=10, # Hessian sum, not row count
gamma=1.0,
reg_alpha=0.1,
reg_lambda=1.0,
scale_pos_weight=class_weight_ratio,
objective='binary:logistic',
eval_metric='logloss',
tree_method='hist',
random_state=42,
early_stopping_rounds=50,
)
model.fit(X_train, y_train, eval_set=[(X_val, y_val)], verbose=False)
return model
| Концепция | XGBoost | ЛайтГБМ | CatBoost |
|---|---|---|---|
| Количество деревьев | n_estimators |
n_estimators |
iterations |
| Штраф L2 | reg_lambda |
reg_lambda |
l2_leaf_reg |
| Выборка столбца | colsample_bytree |
colsample_bytree |
rsm |
| Классовый дисбаланс | scale_pos_weight |
scale_pos_weight |
auto_class_weights='Balanced' |
| Ранняя остановка | early_stopping_rounds |
lgb.early_stopping() обратный вызов |
early_stopping_rounds |
Важность SHAP в складках как детектор альфа-распада

В блоге уже рассматривается SHAP на модели повышения градиента — TreeExplainer, сводные графики и как их читать. Что не рассматривается, так это использование SHAP продольно: один объяснитель на каждый сгиб, отслеживающий среднюю абсолютную атрибуцию каждого признака с течением времени.
def shap_over_time(models, test_sets, feature_names) -> pd.DataFrame:
"""
Track SHAP-based feature importance across walk-forward folds.
Rows are folds, columns are features.
"""
importance_over_time = []
for fold_idx, (model, X_test) in enumerate(zip(models, test_sets)):
explainer = shap.TreeExplainer(model)
shap_values = explainer.shap_values(X_test)
mean_abs_shap = np.abs(shap_values).mean(axis=0)
importance_over_time.append(
pd.Series(mean_abs_shap, index=feature_names, name=fold_idx)
)
return pd.DataFrame(importance_over_time)
Результатом является матрица кратности × признаков, в которой есть три различимые фигуры:
- Монотонное снижение — край объекта затухает. Кандидат на отстранение или на расследование того, что изменилось в структуре рынка.
- Высокая дисперсия, отсутствие тренда — особенностью является шум, который модель фиксирует в некоторых режимах. Это тот же самый сигнал, который анализ плато определяет количественные параметры, применяемые к объектам.
- Поэтапное изменение режима — важность падает в определенный момент и остается на низком уровне. Обычно это связано с событием обмена, листинга или структуры комиссий, а не с альфа-распадом.
Ловушка заключается в том, что среднее значение абсолютного SHAP не сравнимо по разным значениям при изменении общей достоверности модели: модель, которая прогнозирует значение ближе к 0,5 повсюду, дает меньшие значения атрибуции для каждого признака одновременно. Перед сравнением нормализуйте значения каждой складки до суммы, равной 1, чтобы вы считывали относительные изменения важности, а не сдвиги достоверности.
Почему деревья, коротко

Гринштайн, Ояллон и Варокво (NeurIPS 2022) протестировали ансамбли деревьев на предмет глубокого обучения в 45 наборах табличных данных и выделили три структурных свойства, которые благоприятствуют деревьям — все три описывают финансовые данные:
- Нерегулярные целевые функции. Возврат не является гладким; они имеют разрывы, изменения режима и пороговые эффекты. Разделение по осям фиксирует их без аппроксимации гладкой поверхности.
- Неинформативные функции. Альфа-конвейер генерирует сотни кандидатов, большинство из которых представляют собой шум. Деревья выбираются при каждом разбиении; нейронные сети распределяют мощность по всем входам, тратя параметры на шум.
- Невращательная инвариантность. Объем не является взаимозаменяемым с волатильностью. Нейронные сети по умолчанию инвариантны относительно вращения, рассматривая линейные комбинации признаков как эквиваленты оригиналам, что просто неверно для признаков с отличным семантическим значением.
Что касается практической стороны этого компромисса — размера данных, задержки, усилий по разработке функций, интерпретируемости, адаптации режима — в блоге уже есть полная таблица решений в моделировании распространения с помощью машинного обучения.
Разработка функций

import pandas as pd
import numpy as np
def build_features(df: pd.DataFrame) -> pd.DataFrame:
"""
Build trading features from OHLCV data.
Expects columns: open, high, low, close, volume, timestamp
"""
feat = pd.DataFrame(index=df.index)
feat['return_1'] = df['close'].pct_change(1)
feat['return_5'] = df['close'].pct_change(5)
feat['return_15'] = df['close'].pct_change(15)
feat['return_60'] = df['close'].pct_change(60)
log_ret = np.log(df['close'] / df['close'].shift(1))
feat['volatility_20'] = log_ret.rolling(20).std()
feat['volatility_60'] = log_ret.rolling(60).std()
feat['vol_ratio'] = feat['volatility_20'] / feat['volatility_60']
feat['parkinson_vol'] = np.sqrt(
(1 / (4 * np.log(2)))
* (np.log(df['high'] / df['low']) ** 2).rolling(20).mean()
)
feat['volume_sma_ratio'] = df['volume'] / df['volume'].rolling(20).mean()
feat['volume_std_20'] = df['volume'].rolling(20).std()
feat['obv'] = (np.sign(df['close'].diff()) * df['volume']).cumsum()
feat['obv_slope'] = feat['obv'].diff(5) / feat['obv'].shift(5)
feat['high_low_range'] = (df['high'] - df['low']) / df['close']
feat['close_position'] = (df['close'] - df['low']) / (df['high'] - df['low'])
feat['gap'] = df['open'] / df['close'].shift(1) - 1
delta = df['close'].diff()
gain = delta.clip(lower=0).rolling(14).mean()
loss = (-delta.clip(upper=0)).rolling(14).mean()
feat['rsi_14'] = 100 - 100 / (1 + gain / loss)
ema_12 = df['close'].ewm(span=12).mean()
ema_26 = df['close'].ewm(span=26).mean()
feat['macd'] = (ema_12 - ema_26) / df['close']
feat['macd_signal'] = feat['macd'].ewm(span=9).mean()
feat['macd_hist'] = feat['macd'] - feat['macd_signal']
for window in [10, 20, 50]:
sma = df['close'].rolling(window).mean()
feat[f'distance_sma_{window}'] = (df['close'] - sma) / sma
std = df['close'].rolling(window).std()
feat[f'bb_position_{window}'] = (df['close'] - sma) / (2 * std)
return feat
Каждая приведенная выше функция является причинно-следственной по своей конструкции — только операции прокатки и расширения, без статистики всей выборки. Это намеренно: z-оценка целого ряда является единственной наиболее распространенной утечкой в конвейере такого типа, и ее влияние на отчеты Шарпа измеряется в таксономии прогнозируемого смещения.
Диапазоны гиперпараметров для XGBoost для финансовых данных

Целью является не максимальная производительность внутри выборки, а максимальная стабильность вне выборки. Сначала регуляризация, затем сложность:
| Параметр | Типичный диапазон | Цель |
|---|---|---|
max_depth |
3--7 | Ограничивает порядок взаимодействия. Более глубокие деревья моделируют взаимодействия более высокого порядка, но переобучение происходит быстрее. Начните с 4. |
min_child_weight |
5--100 | Минимальная сумма Гессе в листе. Перенастраивайтесь всякий раз, когда вы меняете scale_pos_weight или цель. |
learning_rate |
0,005--0,05 | Усадка. Более низкие значения требуют большего количества деревьев, но лучше обобщают. |
subsample |
0,5--0,8 | Выборка строк для каждого дерева. Добавляет случайность, уменьшает переобучение. |
colsample_bytree |
0,5--0,8 | Выборка столбцов для каждого дерева. Критический со многими коррелирующими функциями. |
gamma |
0,5--5,0 | Минимальное уменьшение потерь при сплите. Действует как порог обрезки. |
reg_alpha (Л1) |
0,01--1,0 | L1 по весу листьев. Поощряет разреженность. |
reg_lambda (Л2) |
0,1--10,0 | L2 по весу листьев. Предотвращает большие конечные значения. |
О самой процедуре поиска — TPE, постоянстве исследования и о том, почему байесовский поиск лучше координатного спуска — см. Оптуна против координатного спуска. Какой бы подсчет судебных процессов вы там ни использовали, его потом придется отнести к дефляции Шарпа.
Что эта статья намеренно оставляет другим сообщениям

Основа модели повышения градиента уже описана здесь с приложенными измерениями:
- Разделение проверки. Поступательная оптимизация для фиксированных и подвижных окон и скорости деградации;
purged_walk_forwardв моделирование распространения с помощью машинного обучения для раскольника с реальным пробелом в чистке/эмбарго. Не отправляйте его без очистки — перекрывающиеся цели переднего окна просачиваются через границы складок. - Переобучение элементов управления. Разрыв в обучении/тестировании и стабильность характеристик перекрестной складки, количественно выраженная в анализе плато и вероятность переоснащения бэктеста.
- Прогнозы PnL. Векторизованный
signal * shifted-returnТестирование на исторических данных с фиксированными затратами на bps позволяет получить Шарпа из нескольких удачных сделок — см. проектирование целевой функции и PnL за активное время. Снижение количества попыток Шарпа после поиска: выкачивание Шарпа, и честный негатив о том, как это выглядит, когда край ненастоящий. - Транзакционные издержки. Модели издержек проскальзывания и комиссии мейкера-тейкера. Модель направления с точностью 53% живет или умирает в зависимости от того, какую модель затрат вы выберете.
Еще три ловушки обозначены здесь как гипотезы, а не как результаты: ошибка выживаемости из-за обучения только активам, включенным в список; нестационарность целевых показателей необработанной доходности, при которых бета-остаточная доходность может вести себя лучше; и частота переобучения, где диагностика SHAP-over-folds является естественным монитором дрейфа.
Заключение

Классификация обратного направления — это несбалансированная проблема, порог принятия решения которой никто не выбрал, а три стандартных решения не являются взаимозаменяемыми. scale_pos_weight это одна линия, но она декалибрует вероятности и бесшумно движется min_child_weight. Фокальная потеря требует гессиана, который приближается к большинству реализаций. Оптимизация порога оставляет модель в покое и помещает релевантное для торговли ограничение — точность — туда, где оно должно быть, но только в том случае, если порог переопределяется для каждого раза на данных, которые поступают после обучения.
Кто из них выиграет, это эмпирический вопрос о ваших данных, вашем пороге и вашей модели затрат. Таблица вверху — это ответ для одного набора данных; запуск его на вашем компьютере обходится дешевле, чем время настройки, потраченное на угадывание.
Дальнейшее чтение:
- Гринштайн и др., "Почему древовидные модели по-прежнему превосходят глубокое обучение на типичных табличных данных?" (NeurIPS 2022)
- Ванг и др., "Imbalance-XGBoost: использование взвешенных и фокальных потерь для классификации с несбалансированными бинарными метками"
- Чен и Гестрин, "XGBoost: масштабируемая система градиентного бустинга" (KDD 2016)
- Ке и др., "LightGBM: высокоэффективное дерево решений с градиентным бустингом" (NeurIPS 2017)
- Прохоренкова и др., "CatBoost: unbiased boosting with categorical features" (NeurIPS 2018)
- Документация XGBoost: Примечания по настройке параметров
Авторы
Инженер торговых систем
Разработка торговых ботов с 2017 года: межбиржевой арбитраж (подключал до 30 бирж), парный арбитраж на коинтеграции между спотом и фьючерсами, скальпинг, фронтраннинг, торговля по новостям, сентиментный анализ, трендовые алгоритмы, а также алгоритмы управления и балансировки портфелей. Делает выставление ордеров до 1 мс, warehouse для big data, бэктестинг-движки, AI-агентов и интерфейсы для ботов (в т.ч. open-source profitmaker.cc). Стек: JS/TS, Python, Rust/Zig/Go, DevOps, backend, frontend, архитектура.