📝

Draft article

This draft is visible to admins and superusers only. Sign in with an authorized account.

← К списку статей
August 25, 2026
5 мин. чтения

XGBoost for Return Direction: Class Imbalance and Decision Thresholds

XGBoost for Return Direction: Class Imbalance and Decision Thresholds
#machine-learning
#xgboost
#gradient-boosting
#quant
#prediction

Обучите классификатор предсказывать, превысит ли доходность следующего часа +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,
    ...
)

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

Побочный эффект легко не заметить: он также масштабирует гессианцы. С min_child_weight является порогом суммы гессианов в листе (см. ниже), повторное взвешивание потери незаметно меняет степень агрессивности разделения дерева. А min_child_weight настроен на scale_pos_weight=1 больше не означает одно и то же scale_pos_weight=3. Перенастройте оба вместе, а не последовательно.

Второй побочный эффект: выходные вероятности больше не калибруются. predict_proba возвращает что-то монотонное с истинной вероятностью, но не равное ей, что делает любое определение размера позиции в дальнейшем, основанное на этих числах, неправильным.

Подход 2: потеря фокуса

Потеря фокуса снижает вес простых примеров независимо от класса и концентрирует обучение на сложных, неоднозначных образцах. Это привлекательная основа для прогнозирования доходности, где граница между баром +0,5% и баром +0,4% в основном представляет собой шум, хотя "сосредоточиться на неоднозначных случаях" и "сосредоточиться на необучаемых случаях" — одна и та же инструкция, когда метки настолько зашумлены; именно поэтому нужно измерять, а не предполагать.

FL(pt)=αt(1pt)γlog(pt)\text{FL}(p_t) = -\alpha_t (1 - p_t)^{\gamma} \log(p_t)

где ptp_t - прогнозируемая вероятность для истинного класса, αt\alpha_t балансирует веса классов и γ\gamma (обычно 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 — это сумма Гессе, а не количество строк

Разбиение дерева с поддержкой кривизны

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

L(t)i=1n[gift(xi)+12hift2(xi)]+Ω(ft)\mathcal{L}^{(t)} \approx \sum_{i=1}^{n} \Bigl[ g_i f_t(\mathbf{x}_i) + \frac{1}{2} h_i f_t^2(\mathbf{x}_i) \Bigr] + \Omega(f_t)

с gi=l/y^i(t1)g_i = \partial l / \partial \hat{y}_i^{(t-1)}, hi=2l/(y^i(t1))2h_i = \partial^2 l / \partial (\hat{y}_i^{(t-1)})^2и

Ω(f)=γT+12λj=1Twj2\Omega(f) = \gamma T + \frac{1}{2}\lambda \sum_{j=1}^{T} w_j^2

где TT количество листьев, wjw_j вес листа, γ\gamma штраф за лист, и λ\lambda термин L2. Решение оптимального веса листа дает wj=ijgi/(ijhi+λ)w_j^* = -\sum_{i \in j} g_i / (\sum_{i \in j} h_i + \lambda).

Сумма Гессе находится в знаменателе, и в этом вся суть. min_child_weight пороговые значения эта сумма, а не количество строк в листе. В случае потери журнала, hi=pi(1pi)h_i = p_i(1 - p_i), который является крупнейшим в p=0.5p = 0.5 и падает до нуля по мере того, как прогнозы приближаются к достоверности. Таким образом, лист, полный уверенно классифицированных столбцов, имеет крошечную сумму гессе и его обрезают, в то время как лист, содержащий несколько действительно неоднозначных столбцов, может выжить.

Для шумных финансовых компаний именно такое поведение и желательно, и оно объясняет практические последствия:

  • Повышение 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)

Результатом является матрица кратности × признаков, в которой есть три различимые фигуры:

  1. Монотонное снижение — край объекта затухает. Кандидат на отстранение или на расследование того, что изменилось в структуре рынка.
  2. Высокая дисперсия, отсутствие тренда — особенностью является шум, который модель фиксирует в некоторых режимах. Это тот же самый сигнал, который анализ плато определяет количественные параметры, применяемые к объектам.
  3. Поэтапное изменение режима — важность падает в определенный момент и остается на низком уровне. Обычно это связано с событием обмена, листинга или структуры комиссий, а не с альфа-распадом.

Ловушка заключается в том, что среднее значение абсолютного SHAP не сравнимо по разным значениям при изменении общей достоверности модели: модель, которая прогнозирует значение ближе к 0,5 повсюду, дает меньшие значения атрибуции для каждого признака одновременно. Перед сравнением нормализуйте значения каждой складки до суммы, равной 1, чтобы вы считывали относительные изменения важности, а не сдвиги достоверности.

Почему деревья, коротко

Дерево решений в нелинейном рыночном ландшафте

Гринштайн, Ояллон и Варокво (NeurIPS 2022) протестировали ансамбли деревьев на предмет глубокого обучения в 45 наборах табличных данных и выделили три структурных свойства, которые благоприятствуют деревьям — все три описывают финансовые данные:

  1. Нерегулярные целевые функции. Возврат не является гладким; они имеют разрывы, изменения режима и пороговые эффекты. Разделение по осям фиксирует их без аппроксимации гладкой поверхности.
  2. Неинформативные функции. Альфа-конвейер генерирует сотни кандидатов, большинство из которых представляют собой шум. Деревья выбираются при каждом разбиении; нейронные сети распределяют мощность по всем входам, тратя параметры на шум.
  3. Невращательная инвариантность. Объем не является взаимозаменяемым с волатильностью. Нейронные сети по умолчанию инвариантны относительно вращения, рассматривая линейные комбинации признаков как эквиваленты оригиналам, что просто неверно для признаков с отличным семантическим значением.

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

Разработка функций

Рыночные сигналы превращаются в характеристики

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, постоянстве исследования и о том, почему байесовский поиск лучше координатного спуска — см. Оптуна против координатного спуска. Какой бы подсчет судебных процессов вы там ни использовали, его потом придется отнести к дефляции Шарпа.

Что эта статья намеренно оставляет другим сообщениям

Исследовать пограничные ворота

Основа модели повышения градиента уже описана здесь с приложенными измерениями:

Еще три ловушки обозначены здесь как гипотезы, а не как результаты: ошибка выживаемости из-за обучения только активам, включенным в список; нестационарность целевых показателей необработанной доходности, при которых бета-остаточная доходность может вести себя лучше; и частота переобучения, где диагностика SHAP-over-folds является естественным монитором дрейфа.

Заключение

Решена ситуация с ускорением принятия решений

Классификация обратного направления — это несбалансированная проблема, порог принятия решения которой никто не выбрал, а три стандартных решения не являются взаимозаменяемыми. scale_pos_weight это одна линия, но она декалибрует вероятности и бесшумно движется min_child_weight. Фокальная потеря требует гессиана, который приближается к большинству реализаций. Оптимизация порога оставляет модель в покое и помещает релевантное для торговли ограничение — точность — туда, где оно должно быть, но только в том случае, если порог переопределяется для каждого раза на данных, которые поступают после обучения.

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


Дальнейшее чтение:

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

Авторы

Eugen Soloviov
Eugen Soloviov

Инженер торговых систем

Разработка торговых ботов с 2017 года: межбиржевой арбитраж (подключал до 30 бирж), парный арбитраж на коинтеграции между спотом и фьючерсами, скальпинг, фронтраннинг, торговля по новостям, сентиментный анализ, трендовые алгоритмы, а также алгоритмы управления и балансировки портфелей. Делает выставление ордеров до 1 мс, warehouse для big data, бэктестинг-движки, AI-агентов и интерфейсы для ботов (в т.ч. open-source profitmaker.cc). Стек: JS/TS, Python, Rust/Zig/Go, DevOps, backend, frontend, архитектура.

Newsletter

Будьте в курсе событий

Подпишитесь на нашу рассылку, чтобы получать эксклюзивную аналитику по AI-трейдингу и обновления платформы.

Мы уважаем вашу конфиденциальность. Отписаться можно в любой момент.