English Deutsch 日本語
preview
Архитектура системы машинного обучения в MetaTrader 5 (Часть 4): Скрытый изъян пайплайна финансового ML — одновременность меток

Архитектура системы машинного обучения в MetaTrader 5 (Часть 4): Скрытый изъян пайплайна финансового ML — одновременность меток

MetaTrader 5Трейдинг |
119 0
Patrick Murimi Njoroge
Patrick Murimi Njoroge

Введение

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

Однако реализация метода тройного барьера приводит к важной проблеме, которую большинство специалистов по машинному обучению упускают из виду: одновременность меток. Когда мы применяем барьеры к финансовым данным, полученные метки часто перекрываются во времени. Несколько наблюдений могут быть «активными» одновременно: их информационные интервалы охватывают пересекающиеся периоды, создавая временные зависимости, которые нарушают фундаментальное предположение большинства алгоритмов машинного обучения о том, что обучающие выборки являются независимыми и одинаково распределёнными (IID).

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

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

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


Взвешивание наблюдений — решение проблемы одновременности

Проблема одновременности

Большинство исследователей машинного обучения вне финансовой сферы могут предполагать, что наблюдения можно считать IID-наблюдениями, то есть являются независимыми и одинаково распределёнными (IID — Independent and Identically Distributed). Например, можно взять анализы крови у большого числа пациентов и измерить уровень холестерина. Конечно, различные общие базовые факторы будут сдвигать среднее значение и стандартное отклонение распределения холестерина, но сами образцы всё равно остаются независимыми: одно наблюдение соответствует одному субъекту. Предположим, вы взяли эти анализы крови, а кто-то в вашей лаборатории пролил кровь из каждой пробирки в следующие девять пробирок справа. То есть пробирка 10 содержит кровь пациента 10, но также кровь пациентов с 1 по 9. Пробирка 11 содержит кровь пациента 11, но также кровь пациентов со 2 по 10, и так далее. Теперь вам нужно определить признаки, предсказывающие высокий уровень холестерина — диету, физическую активность, возраст и т. д., — не зная наверняка уровень холестерина каждого пациента. Это эквивалентно той задаче, с которой мы сталкиваемся в финансовом ML, с дополнительным осложнением: схема такого “перетекания” или “загрязнения” недетерминирована и заранее неизвестна.

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

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

Математическая основа

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

Подход Лопеса де Прадо рассчитывает это через матрицу перекрытия меток. Для любых двух наблюдений i и j мы определяем, насколько их соответствующие "информационные множества" перекрываются во времени. Если наблюдение i использует информацию с момента t₁ до t₂ для своей метки, а наблюдение j использует информацию с момента t₃ до t₄, то их перекрытие — это пересечение этих временных интервалов.

Процесс включает три шага:

  1. Подсчёт одновременности: для каждого бара в данных подсчитать, сколько событий "активны" в этот момент времени. Если три сделки открыты одновременно, каждый бар в течение этого периода имеет одновременность, равную 3.
  2. Уникальность: для каждого события рассчитать обратную величину одновременности, то есть 1/одновременность, на каждом баре в течение срока жизни события, а затем усреднить эти значения. Если событие охватывает бары с одновременностью [3, 4, 3, 2], его средняя уникальность равна (1/3 + 1/4 + 1/3 + 1/2) / 4 ≈ 0,354.
  3. Вес выборки: это значение уникальности становится весом данного наблюдения при обучении модели.

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

Это создаёт естественную схему взвешивания, в которой:

  • независимые наблюдения получают полный вес — 1,0;
  • частично перекрывающиеся наблюдения получают пропорционально уменьшенный вес — 0,3–0,7;
  • сильно перекрывающиеся наблюдения получают минимальный вес — меньше 0,3.

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

Реализация: вычисление одновременности

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

Первая функция вычисляет, сколько событий активно в каждый момент времени:

def num_concurrent_events(close_series_index, label_endtime, molecule):
    """
    Advances in Financial Machine Learning, Snippet 4.1, page 60.

    Estimating the Uniqueness of a Label

    This function uses close series prices and label endtime (when the first barrier is touched) 
    to compute the number of concurrent events per bar.

    :param close_series_index: (pd.Series) Close prices index
    :param label_endtime: (pd.Series) Label endtime series (t1 for triple barrier events)
    :param molecule: (an array) A set of datetime index values for processing
    :return: (pd.Series) Number concurrent labels for each datetime index
    """
    # Find events that span the period [molecule[0], molecule[1]]
    label_endtime = label_endtime.fillna(
        close_series_index[-1]
    )  # Unclosed events still must impact other weights
    label_endtime = label_endtime[
        label_endtime >= molecule[0]
    ]  # Events that end at or after molecule[0]
    # Events that start at or before t1[molecule].max()
    label_endtime = label_endtime.loc[: label_endtime[molecule].max()]

    # Count events spanning a bar
    nearest_index = close_series_index.searchsorted(
        pd.DatetimeIndex([label_endtime.index[0], label_endtime.max()])
    )
    count = pd.Series(0, index=close_series_index[nearest_index[0] : nearest_index[1] + 1])
    for t_in, t_out in label_endtime.items():
        count.loc[t_in:t_out] += 1
    return count.loc[molecule[0] : label_endtime[molecule].max()]

Что на самом деле делает этот код: Представьте, что у вас есть три сделки:

  • Сделка A: открывается в 10:00, закрывается в 10:30
  • Сделка B: открывается в 10:15, закрывается в 10:45
  • Сделка C: открывается в 10:50, закрывается в 11:00

В 10:20 одновременно открыты сделки A и B, поэтому count[10:20] = 2. В 10:55 открыта только сделка C, поэтому count[10:55] = 1. Эта функция строит всю такую временную шкалу.

Функция-обёртка распараллеливает это вычисление, используя mp_pandas_obj — утилиту для многопроцессорной обработки, см. multiprocess.py, — по всему вашему набору данных.

def get_num_conc_events(events, close, num_threads=4, verbose=True):
    num_conc_events = mp_pandas_obj(
        num_concurrent_events,
        ("molecule", events.index),
        num_threads,
        close_series_index=close.index,
        label_endtime=events["t1"],
        verbose=verbose,
    )
    return num_conc_events

Вычисление средней уникальности

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

def _get_average_uniqueness(label_endtime, num_conc_events, molecule):
    """
    Advances in Financial Machine Learning, Snippet 4.2, page 62.

    Estimating the Average Uniqueness of a Label

    This function uses close series prices and label endtime (when the first barrier is touched)
    to compute the number of concurrent events per bar.

    :param label_endtime: (pd.Series) Label endtime series (t1 for triple barrier events)
    :param num_conc_events: (pd.Series) Number of concurrent labels (output from num_concurrent_events function).
    :param molecule: (an array) A set of datetime index values for processing.
    :return: (pd.Series) Average uniqueness over event's lifespan.
    """
    wght = {}
    for t_in, t_out in label_endtime.loc[molecule].items():
        wght[t_in] = (1.0 / num_conc_events.loc[t_in:t_out]).mean()

    wght = pd.Series(wght)
    return wght

Функция-оркестратор объединяет всё вместе:

def get_av_uniqueness_from_triple_barrier(
    triple_barrier_events, close_series, num_threads, num_conc_events=None, verbose=True
):
    """
    This function is the orchestrator to derive average sample uniqueness from a dataset labeled by the triple barrier
    method.

    :param triple_barrier_events: (pd.DataFrame) Events from labeling.get_events()
    :param close_series: (pd.Series) Close prices.
    :param num_threads: (int) The number of threads concurrently used by the function.
    :param num_conc_events: (pd.Series) Number concurrent labels for each datetime index
    :param verbose: (bool) Flag to report progress on asynch jobs
    :return: (pd.Series) Average uniqueness over event's lifespan for each index in triple_barrier_events
    """
    out = pd.DataFrame()

    # Create processing pipeline for num_conc_events
    def process_concurrent_events(ce):
        """Process concurrent events to ensure proper format and indexing."""
        ce = ce.loc[~ce.index.duplicated(keep="last")]
        ce = ce.reindex(close_series.index).fillna(0)
        if isinstance(ce, pd.Series):
            ce = ce.to_frame()
        return ce

    # Handle num_conc_events (whether provided or computed)
    if num_conc_events is None:
        num_conc_events = get_num_conc_events(
            triple_barrier_events, close_series, num_threads, verbose
        )
        processed_ce = process_concurrent_events(num_conc_events)
    else:
        # Ensure precomputed value matches expected format
        processed_ce = process_concurrent_events(num_conc_events.copy())

    # Verify index compatibility
    missing_in_close = processed_ce.index.difference(close_series.index)
    assert missing_in_close.empty, (
        f"num_conc_events contains {len(missing_in_close)} " "indices not in close_series"
    )

    out["tW"] = mp_pandas_obj(
        _get_average_uniqueness,
        ("molecule", triple_barrier_events.index),
        num_threads,
        label_endtime=triple_barrier_events["t1"],
        num_conc_events=processed_ce,
        verbose=verbose,
    )
    return out

Атрибуция доходности

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

def _apply_weight_by_return(label_endtime, num_conc_events, close_series, molecule):
    """
    Advances in Financial Machine Learning, Snippet 4.10, page 69.

    Determination of Sample Weight by Absolute Return Attribution

    Derives sample weights based on concurrency and return. Works on a set of
    datetime index values (molecule). This allows the program to parallelize the processing.

    :param label_endtime: (pd.Series) Label endtime series (t1 for triple barrier events)
    :param num_conc_events: (pd.Series) Number of concurrent labels (output from num_concurrent_events function).
    :param close_series: (pd.Series) Close prices
    :param molecule: (an array) A set of datetime index values for processing.
    :return: (pd.Series) Sample weights based on number return and concurrency for molecule
    """

    ret = np.log(close_series).diff()  # Log-returns, so that they are additive

    weights = {}
    for t_in, t_out in label_endtime.loc[molecule].items():
        # Weights depend on returns and label concurrency
        weights[t_in] = (ret.loc[t_in:t_out] / num_conc_events.loc[t_in:t_out]).sum()

    weights = pd.Series(weights)
    return weights.abs()

Полная реализация с корректной обработкой данных:

def get_weights_by_return(
    triple_barrier_events,
    close_series,
    num_threads=4,
    num_conc_events=None,
    verbose=True,
):
    """
    Determination of Sample Weight by Absolute Return Attribution
    Modified to ensure compatibility with precomputed num_conc_events

    :param triple_barrier_events: (pd.DataFrame) Events from labeling.get_events()
    :param close_series: (pd.Series) Close prices
    :param num_threads: (int) Number of threads
    :param num_conc_events: (pd.Series) Precomputed concurrent events count
    :param verbose: (bool) Report progress
    :return: (pd.Series) Sample weights
    """
    # Validate input
    assert not triple_barrier_events.isnull().values.any(), "NaN values in events"
    assert not triple_barrier_events.index.isnull().any(), "NaN values in index"

    # Create processing pipeline for num_conc_events
    def process_concurrent_events(ce):
        """Process concurrent events to ensure proper format and indexing."""
        ce = ce.loc[~ce.index.duplicated(keep="last")]
        ce = ce.reindex(close_series.index).fillna(0)
        if isinstance(ce, pd.Series):
            ce = ce.to_frame()
        return ce

    # Handle num_conc_events (whether provided or computed)
    if num_conc_events is None:
        num_conc_events = mp_pandas_obj(
            num_concurrent_events,
            ("molecule", triple_barrier_events.index),
            num_threads,
            close_series_index=close_series.index,
            label_endtime=triple_barrier_events["t1"],
            verbose=verbose,
        )
        processed_ce = process_concurrent_events(num_conc_events)
    else:
        # Ensure precomputed value matches expected format
        processed_ce = process_concurrent_events(num_conc_events.copy())

        # Verify index compatibility
        missing_in_close = processed_ce.index.difference(close_series.index)
        assert missing_in_close.empty, (
            f"num_conc_events contains {len(missing_in_close)} " "indices not in close_series"
        )

    # Compute weights using processed concurrent events
    weights = mp_pandas_obj(
        _apply_weight_by_return,
        ("molecule", triple_barrier_events.index),
        num_threads,
        label_endtime=triple_barrier_events["t1"],
        num_conc_events=processed_ce,  # Use processed version
        close_series=close_series,
        verbose=verbose,
    )

    # Normalize weights
    weights *= weights.shape[0] / weights.sum()
    return weights

Взвешивание с временным затуханием

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

def get_weights_by_time_decay(
    triple_barrier_events,
    close_series,
    num_threads=4,
    last_weight=1,
    linear=True,
    av_uniqueness=None,
    verbose=True,
):
    """
    Advances in Financial Machine Learning, Snippet 4.11, page 70.
    Implementation of Time Decay Factors
    """
    assert (
        bool(triple_barrier_events.isnull().values.any()) is False
        and bool(triple_barrier_events.index.isnull().any()) is False
    ), "NaN values in triple_barrier_events, delete nans"

    # Get average uniqueness if not provided
    if av_uniqueness is None:
        av_uniqueness = get_av_uniqueness_from_triple_barrier(
            triple_barrier_events, close_series, num_threads, verbose=verbose
        )
    elif isinstance(av_uniqueness, pd.Series):
        av_uniqueness = av_uniqueness.to_frame()

    # Calculate cumulative time weights
    cum_time_weights = av_uniqueness["tW"].sort_index().cumsum()

    if linear:
        # Apply linear decay (your existing linear code is correct)
        if last_weight >= 0:
            slope = (1 - last_weight) / cum_time_weights.iloc[-1]
        else:
            slope = 1 / ((last_weight + 1) * cum_time_weights.iloc[-1])
        const = 1 - slope * cum_time_weights.iloc[-1]
        weights = const + slope * cum_time_weights
        weights[weights < 0] = 0
        return weights
    else:
        # Apply exponential decay
        if last_weight == 1:
            return pd.Series(1.0, index=cum_time_weights.index)

        elif cum_time_weights.iloc[-1] == 0:
            return pd.Series(1.0, index=cum_time_weights.index)

        # Calculate normalized position (0 = newest, 1 = oldest)
        elif last_weight > 0:
            # For last_weight > 0, use standard exponential decay
            normalized_position = (cum_time_weights - cum_time_weights.iloc[0]) / (
                cum_time_weights.iloc[-1] - cum_time_weights.iloc[0]
            )
            weights = last_weight**normalized_position
        elif last_weight < 0:
            # For last_weight < 0, implement cutoff (similar to linear case)
            # This is more complex for exponential - you might want to reconsider this case
            cutoff_threshold = abs(last_weight)
            normalized_position = (cum_time_weights - cum_time_weights.iloc[0]) / (
                cum_time_weights.iloc[-1] - cum_time_weights.iloc[0]
            )
            weights = (1 - cutoff_threshold)**normalized_position
            weights[weights < 0] = 0

        return weights

Коэффициенты временного затухания

Рисунок 1. Коэффициенты временного затухания: линейное против экспоненциального

Веса классов

Помимо весов выборок, часто полезно применять веса классов. Веса классов — это веса, которые компенсируют недопредставленность классов. Это особенно важно в задачах классификации, где наиболее значимые классы встречаются редко (King and Zeng [2001]). Например, предположим, что вы хотите прогнозировать кризис ликвидности, такой как flash crash (мгновенный обвал) 6 мая 2010 года. Такие события редки по сравнению с миллионами наблюдений, происходящих между ними. Если мы не назначим более высокие веса выборкам, связанным с такими редкими метками, алгоритм машинного обучения будет максимизировать точность для наиболее распространённых меток, а flash crash будут рассматриваться как выбросы, а не как редкие события.

Библиотеки машинного обучения обычно реализуют функциональность для работы с весами классов. Например, sklearn штрафует ошибки на выборках класса class[j], j = 1, …, J, с весом class_weight[j], а не 1. Соответственно, более высокие веса класса для метки j заставят алгоритм добиваться более высокой точности по j. Если веса классов в сумме не дают J, эффект эквивалентен изменению параметра регуляризации классификатора.

В финансовых приложениях стандартные метки классификационного алгоритма имеют вид {−1, 1}, где нулевой — или нейтральный — случай подразумевается прогнозом с вероятностью лишь немного выше 0,5 и ниже некоторого нейтрального порога. Нет оснований предпочитать точность по одному классу точности по другому, поэтому хорошим значением по умолчанию является class_weight='balanced'. Этот выбор перевзвешивает наблюдения так, чтобы имитировать ситуацию, в которой все классы встречаются с одинаковой частотой. В контексте бэггинг-классификаторов можно рассмотреть аргумент class_weight='balanced_subsample', что означает, что class_weight='balanced' будет применяться к внутривыборочным бутстрэп-выборкам, а не ко всему набору данных. Для полного понимания полезно ознакомиться с исходным кодом реализации class_weight в sklearn.

(Лопес де Прадо, 2018, стр. 71)


Практическая реализация

Работа с не-IID данными в бэггинг

Нарушение предположения IID в финансовых данных делает стандартный бэггинг неэффективным, поскольку он создаёт бутстрэп-выборки, страдающие от серийной корреляции. В книге Advances in Financial Machine Learning предлагаются три различных метода преодоления этой фундаментальной проблемы, при этом взвешивание наблюдений служат основой для всех трёх подходов.

Метод 1: ограничение размера бутстрэп-выборки

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

  • Основная идея: радикально уменьшить размер каждой бутстрэп-выборки. Выбирая меньшее число наблюдений, мы статистически снижаем вероятность включения нескольких сильно коррелированных точек данных в одну и ту же выборку.
  • Реализация: в sklearn.ensemble.BaggingClassifier это достигается установкой параметра max_samples в значение, значительно меньшее 1,0 — например, 0,5, 0,3 или ниже. Практическая эвристика — установить его равным средней уникальности набора данных: max_samples=out['tW'].mean().
  • Механизм: max_samples контролирует абсолютное или относительное количество выборок, извлекаемых из X для обучения каждого базового оценивателя. Значение max_samples=0.3 означает, что каждый классификатор обучается на случайных 30% исходного набора данных, что принудительно повышает разнообразие за счёт ограничения перекрытия.
  • Плюсы и минусы:
    • Плюс: просто реализовать; требуется изменить всего одну строку кода.
    • Минус: это грубый инструмент; он снижает избыточность, но не ищет активно уникальные наблюдения. Кроме того, он может отбрасывать ценные данные.

Метод 2: взвешивание наблюдение при внутривыборочной оценке

Этот метод корректирует проблему на уровне отдельного базового оценивателя, а не на этапе формирования выборки.

  • Основная идея: использовать стандартную бутстрэп-выборку, но при обучении каждого базового оценивателя применять взвешивание наблюдений — столбец tW, — чтобы заставить модель фокусироваться на уникальных наблюдениях и снижать влияние избыточных.
  • Реализация: после создания бутстрэп-выборки стандартными методами параметр sample_weight для каждого базового оценивателя устанавливается равным заранее рассчитанным весам внутривыборочных наблюдений. Для этого базовый оцениватель должен поддерживать взвешивание наблюдений, например DecisionTreeClassifier из sklearn.
  • Механизм: функция потерь модели модифицируется так, чтобы ошибки на наблюдениях с высоким весом — уникальных наблюдениях — штрафовались сильнее, чем ошибки на наблюдениях с низким весом — избыточных наблюдениях.
  • Плюсы и минусы:
    • Плюс: использует уже рассчитанные взвешивание наблюдений; может комбинироваться с другими методами.
    • Минус: не предотвращает попадание коррелированных данных в бутстрэп-выборку; он лишь смягчает их влияние постфактум.

Метод 3: последовательный бутстрэппинг

Это строгий, целенаправленный подход, предписанный Лопесом де Прадо. Он будет подробно рассмотрен в нашей следующей статье.

  • Основная идея: полностью заменить стандартную случайную выборку интеллектуальным последовательным алгоритмом, который активно обеспечивает уникальность внутри каждой бутстрэп-выборки.
  • Реализация: пользовательская процедура выборки, которая добавляет наблюдения по одному. Новое наблюдение добавляется в текущую выборку только в том случае, если оно достаточно "уникально" — то есть не перекрывается — относительно всех наблюдений, уже находящихся в выборке. Для этого требуется полноценная пользовательская реализация логики ресемплинга.
  • Механизм: метод напрямую использует матрицу одновременности или перекрытия меток, чтобы условно принимать или отклонять каждое кандидатное наблюдение для бутстрэп-выборки.
  • Плюсы и минусы:
    • Плюс: теоретически обоснован; активно формирует максимально разнообразные и декоррелированные выборки.
    • Минус: вычислительно затратен и сложен для корректной реализации.

Синтез и наш подход

Эти три метода образуют иерархию по степени сложности:

  • Метод 1 — ограничение размера выборки — выступает как простая превентивная мера на этапе формирования выборки.
  • Метод 2 — внутривыборочное взвешивание — выступает как корректирующая мера во время обучения модели.
  • Метод 3 — последовательный бутстрэппинг — является комплексным превентивным решением, которое устраняет первопричину на этапе формирования выборки.

Методы 1 и 2 являются взаимодополняющими и могут комбинироваться для подхода "глубокоэшелонированной защиты": Метод 1 снижает вероятность попадания перекрывающихся наблюдений в каждую бутстрэп-выборку, а Метод 2 гарантирует, что любые перекрывающиеся наблюдения, которые всё же попали в выборку, получат соответствующим образом уменьшенное влияние при обучении.

Для целей данной статьи мы используем и Метод 1, и Метод 2 благодаря их взаимодополняющей природе, простоте и демонстрируемой эффективности. Устанавливая max_samples=out['tW'].mean() — Метод 1 — и передавая sample_weight=out['tW'] в метод fit() классификатора — Метод 2, — мы создаём устойчивый пайплайн, который одновременно предотвращает и корректирует избыточность в наших бутстрэп-выборках.

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

Использование весов выборок при обучении модели

Теперь переходим к ключевой части: как мы фактически используем эти веса в нашем пайплайне ML? Сделаем небольшое отступление в тему кросс-валидации и того, почему стандартные её применения не работают в финансах. Это необходимо, поскольку именно эта техника используется для оценки эффективности наших методов взвешивания.

Концептуальная основа финансовой кросс-валидации

Стандартная k-fold кросс-валидация (CV) опирается на предположение, что точки данных являются независимыми и одинаково распределёнными (IID). Финансовые временные ряды нарушают это базовое предположение из-за последовательной корреляции, временных зависимостей и структурных сдвигов. Использование стандартных методов создаёт риск утечки данных, когда информация из будущего непреднамеренно влияет на обучение модели на прошлых данных, что приводит к переобучению и ненадёжным оценкам эффективности.

Рисунок 2 иллюстрирует k разбиений на обучающую и тестовую выборки, выполняемых при k-fold CV, где k = 5. В этой схеме:

  1. набор данных разделяется на k подмножеств;
  2. Для i = 1,…,k

K-fold кросс-валидация

Рисунок 2. Разбиения на обучающую и тестовую выборки в схеме 5-fold CV

  • (a) ML-алгоритм обучается на всех подмножествах, кроме i.
  • (b) Обученный ML-алгоритм тестируется на i.

Чтобы решить эту проблему, Лопес де Прадо вводит две ключевые модификации стандартной k-fold CV:

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

Очистка перекрытий в обучающей выборке

Рисунок 3. Очистка перекрытий в обучающей выборке

Эмбарго на обучающие наблюдения после тестового периода

Рисунок 4. Эмбарго на обучающие наблюдения после тестового периода

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

from typing import Callable

import numpy as np
import pandas as pd
from sklearn.base import ClassifierMixin
from sklearn.metrics import accuracy_score, f1_score, log_loss
from sklearn.model_selection import BaseCrossValidator
from sklearn.model_selection._split import _BaseKFold

from ..cross_validation.scoring import probability_weighted_accuracy

class PurgedKFold(_BaseKFold):
    """
    Extend KFold class to work with labels that span intervals

    The train is purged of observations overlapping test-label intervals
    Test set is assumed contiguous (shuffle=False), w/o training samples in between

    :param n_splits: (int) The number of splits. Default to 3
    :param t1: (pd.Series) The information range on which each record is constructed from
        *t1.index*: Time when the information extraction started.
        *t1.value*: Time when the information extraction ended.
    :param pct_embargo: (float) Percent that determines the embargo size.
    """

    def __init__(self, n_splits=3, t1=None, pct_embargo=0.0):
        if not isinstance(t1, pd.Series):
            raise ValueError("Label Through Dates must be a pd.Series")

        super().__init__(n_splits, shuffle=False, random_state=None)

        self.t1 = t1
        self.pct_embargo = pct_embargo

    def split(self, X, y=None, groups=None):
        """
        The main method to call for the PurgedKFold class

        :param X: (pd.DataFrame) Samples dataset that is to be split
        :param y: (pd.Series) Sample labels series
        :param groups: (array-like), with shape (n_samples,), optional
            Group labels for the samples used while splitting the dataset into
            train/test set.
        :return: (tuple) [train list of sample indices, and test list of sample indices]
        """

        if (X.index == self.t1.index).sum() != len(self.t1):
            raise ValueError("X and ThruDateValues must have the same index")

        indices = np.arange(X.shape[0])
        mbrg = int(X.shape[0] * self.pct_embargo)
        test_starts = [(i[0], i[-1] + 1) for i in np.array_split(np.arange(len(X)), self.n_splits)]

        for i, j in test_starts:
            t0 = self.t1.index[i]  # start of test set
            test_indices = indices[i:j]
            max_t1_idx = self.t1.index.searchsorted(self.t1[test_indices].max())
            train_indices = self.t1.index.searchsorted(self.t1[self.t1 <= t0].index)
            if max_t1_idx < X.shape[0]:  # right train (with embargo)
                train_indices = np.concatenate((train_indices, indices[max_t1_idx + mbrg :]))
            yield train_indices, test_indices


Методология оценки

Методы скоринга

В финансовом машинном обучении выбор правильных метрик оценки имеет решающее значение для анализа эффективности модели. Стандартные метрики, такие как accuracy, могут вводить в заблуждение в финансовом контексте, особенно при работе с несбалансированными наборами данных или приложениями мета-разметки (meta-labeling). Рассмотрим ключевые метрики, используемые для оценки финансовых моделей машинного обучения.

Accuracy

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

Accuracy = (TP + TN) / (TP + TN + FP + FN)

Где:

  • TP = истинно положительные;
  • TN = истинно отрицательные;
  • FP = ложно положительные;
  • FN = ложно отрицательные.

Хотя accuracy даёт общее представление об эффективности, она может быть обманчивой в финансовых приложениях, где распределения классов часто несбалансированы.

Precision

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

Precision = TP / (TP + FP)

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

Recall

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

Recall = TP / (TP + FN)

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

F1 Score

F1 score устраняет ограничения accuracy в несбалансированных сценариях, объединяя precision и recall в одну метрику:

F1 = 2 × (Precision × Recall) / (Precision + Recall)

Эта метрика особенно ценна в приложениях meta-labeling, где отрицательные случаи — метка 0 — часто значительно превосходят по численности положительные случаи — метка 1. В таких ситуациях наивный классификатор, который всегда предсказывает класс большинства, достиг бы высокой accuracy, но при этом не выявлял бы ни одной реальной возможности.

Важное замечание: F1 score становится неопределённым в некоторых вырожденных случаях:

  • когда все наблюдаемые значения отрицательные — нет положительных случаев для расчёта recall;
  • когда все предсказанные значения отрицательные — нет положительных прогнозов для оценки precision.

Scikit-learn обрабатывает эти крайние случаи, возвращая F1 score, равный 0, и выдавая UndefinedMetricWarning.

Понимание вырожденных случаев в бинарной классификации

Таблица ниже обобщает поведение разных метрик в экстремальных сценариях:

Условие
Коллапс
Accuracy
Precision
Recall
F1
Все наблюдаемые значения равны 1
TN=FP=0
=recall
1 [0,1]
[0,1]
Все наблюдаемые значения равны 0
TP=FN=0
[0,1]
0 Не определён
Не определён
Все предсказанные значения равны 1
TN=FN=0
=precision
[0,1]
1 [0,1]
Все предсказанные значения равны 0
TP=FP=0
[0,1]
Не определён
0
Не определён

Эти крайние случаи показывают, почему опора исключительно на accuracy может вводить в заблуждение, а F1 score и log-loss обеспечивают более устойчивую оценку в практических финансовых приложениях.

Log-Loss

Log-loss, или кросс-энтропийная потеря, обеспечивает более тонкую оценку, чем accuracy, поскольку учитывает уверенность прогнозов:

Формула Log-Loss

Где:

  • pn,k = вероятность для прогноза n по классу k
  • Y = 1-of-K бинарная индикаторная матрица формата
  • yn,k = 1 если у наблюдения n есть метка k, и 0 в противном случае

В финансовых приложениях мы обычно используем отрицательный log-loss, чтобы сохранить интуитивную интерпретацию скоринга: более высокие значения лучше. Эта метрика особенно релевантна, потому что:

  1. она учитывает уверенность прогноза: неправильный прогноз с высокой уверенностью штрафуется строже, чем неправильный прогноз с низкой уверенностью;
  2. она согласуется с соображениями PnL: в сочетании с весами выборок, основанными на доходностях, она приближённо отражает влияние классификатора на прибыль и убытки;
  3. она отражает размер позиции: более уверенные прогнозы обычно приводят к более крупным размерам позиций в торговых стратегиях.

В отличие от accuracy, которая рассматривает все ошибки одинаково независимо от уверенности, log-loss обеспечивает более реалистичную оценку потенциального влияния классификатора на торговую эффективность.

Предположим, что классификатор предсказывает две 1, тогда как истинные метки равны 1 и 0. Первый прогноз является попаданием, а второй — промахом, поэтому accuracy составляет 50%. На рисунке 5 показана кросс-энтропийная потеря, когда эти прогнозы получены из вероятностей в диапазоне [0.5, 0.9]. Можно заметить, что в правой части рисунка log-loss велик из-за промахов с высокой вероятностью, хотя accuracy во всех случаях остаётся равной 50%.

Log-loss как функция прогнозируемых вероятностей попадания и промаха

Рисунок 5. Log-loss как функция прогнозируемых вероятностей попадания и промаха

Взвешенная по вероятности точность (PWA)

Probability Weighted Accuracy, или PWA, расширяет традиционную accuracy, взвешивая правильные прогнозы по уровню уверенности. Правильный прогноз с уверенностью 90% вносит больший вклад, чем правильный прогноз с уверенностью 51%. Это лучше отражает реальную торговлю, где размер позиции определяется на основе уверенности прогноза. PWA штрафует плохие прогнозы, сделанные с высокой уверенностью, строже, чем accuracy, но мягче, чем log-loss.

Probability Weighted Accuracy Formula

где pn = max{pn,k} а yn — индикаторная функция, yn ∈ {0, 1}, где yn = 1 если прогноз был правильным, и yn = 0 в противном случае.

Это эквивалентно стандартной accuracy, когда классификатор имеет абсолютную уверенность в каждом прогнозе, то есть pn = 1 для всех n (Прадо, 2020, стр. 83). Базовая корректировка pn - 1/K гарантирует, что случайное угадывание — вероятность 1/K — получает нулевой вес.

import numpy as np
import pandas as pd
from sklearn.utils.multiclass import unique_labels

def probability_weighted_accuracy(y_true, y_prob, sample_weight=None, labels=None, eps=1e-15):
    """
    Calculates the Probability-Weighted Accuracy (PWA) score.

    PWA is a confidence-weighted accuracy that penalizes high-confidence
    mistakes more severely. This version is compatible with sklearn
    conventions: it accepts a `labels` argument to fix the class order,
    applies probability clipping, and supports sample weights.

    Args:
        y_true (array-like): True class labels, shape (n_samples,).
        y_prob (array-like or DataFrame): Predicted probabilities,
            shape (n_samples, n_classes). If DataFrame, columns must be
            class labels.
        sample_weight (array-like, optional): Per-sample weights.
        labels (array-like, optional): List of all expected class labels
            (in the order corresponding to columns of y_prob).
        eps (float): Small value to clip probabilities into [eps, 1 - eps].

    Returns:
        float: PWA score between 0 and 1.
    """
    # 1) Convert inputs to numpy arrays (or reorder DataFrame)
    y_true = np.asarray(y_true)
    if isinstance(y_prob, pd.DataFrame):
        # If labels given, reorder columns; otherwise infer column order
        cols = labels if labels is not None else y_prob.columns.tolist()
        y_prob = y_prob[cols].to_numpy()
    else:
        y_prob = np.asarray(y_prob)

    # 2) Clip probabilities to avoid zeros or ones
    y_prob = np.clip(y_prob, eps, 1 - eps)

    # 3) Determine class list and validate
    if labels is not None:
        classes = np.asarray(labels)
    else:
        # Infer classes from y_true (sorted)
        classes = unique_labels(y_true)
    n_classes = classes.shape[0]

    # 4) Handle binary case where y_prob might be 1D
    if y_prob.ndim == 1:
        # Interpret as probability of class classes[1]
        y_prob = np.vstack([1 - y_prob, y_prob]).T
        n_classes = 2

    # 5) Shape checks
    if y_prob.ndim != 2 or y_prob.shape[1] != n_classes:
        raise ValueError(
            f"y_prob must be shape (n_samples, n_classes={n_classes}), " f"but got {y_prob.shape}"
        )

    if not np.all(np.isin(y_true, classes)):
        missing = set(y_true) - set(classes)
        raise ValueError(f"y_true contains labels not in `labels`: {missing}")

    # 6) Prepare sample weights
    if sample_weight is None:
        sample_weight = np.ones_like(y_true, dtype=float)
    else:
        sample_weight = np.asarray(sample_weight, dtype=float)
        if sample_weight.shape[0] != y_true.shape[0]:
            raise ValueError("sample_weight must have same length as y_true")

    # 7) Predicted class index and its probability
    pred_idx = np.argmax(y_prob, axis=1)
    p_n = y_prob[np.arange(len(y_true)), pred_idx]

    # 8) Correctness indicator y_n ∈ {0,1}
    #    Map y_true labels to indices in `classes`
    label_to_index = {c: i for i, c in enumerate(classes)}
    true_idx = np.vectorize(label_to_index.get)(y_true)
    y_n = (pred_idx == true_idx).astype(int)

    # 9) Confidence weights: p_n – (1/K)
    baseline = 1.0 / n_classes
    conf_w = p_n - baseline

    # 10) Compute numerator and denominator with sample weights
    numerator = np.sum(sample_weight * y_n * conf_w)
    denominator = np.sum(sample_weight * conf_w)

    # 11) Edge case: no confidence (all p_n == 1/K)
    if np.isclose(denominator, 0.0):
        return 0.5  # random-guess baseline

    # 12) Final PWA score
    return numerator / denominator


Экспериментальная постановка

Данные и торговые стратегии

Мы оцениваем техники взвешивания выборок на барах EUR/USD M5 за период с 2018-01-01 по 2022-12-31. Были протестированы две различные стратегии meta-labeling с использованием целевой волатильности на основе 20-дневного экспоненциально взвешенного скользящего стандартного отклонения.

Стратегия с мета-разметкой на основе полос Боллинджера

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

Конфигурация triple-barrier:

  • Profit Target: 1
  • Stop Loss: 2
  • Time Barrier: 4 часа
  • Minimum Return Threshold: 0.0

Стратегия с мета-разметкой пересечения MA_20_50

Этот классический трендовый подход использует пересечение 20- и 50-периодных скользящих средних в качестве первичных сигналов. Модель meta-labeling учится фильтровать эти сигналы, прогнозируя, какие пересечения с наибольшей вероятностью приведут к прибыльным сделкам.

Конфигурация triple-barrier:

  • Profit Target: 0
  • Stop Loss: 2
  • Time Barrier: 1 день
  • Minimum Return Threshold: 0.0

Оценочная схема

Для каждой стратегии мы обучали классификатор случайного леса (Random Forest) с взвешиванием выборок и без него, используя Purged K-Fold кросс-валидацию для предотвращения утечки данных.

Функция ниже рассчитывает все метрики эффективности, обсуждавшиеся выше:

def ml_cross_val_scores_all(
    classifier: ClassifierMixin,
    X: pd.DataFrame,
    y: pd.Series,
    cv_gen: BaseCrossValidator,
    sample_weight_train: np.ndarray = None,
    sample_weight_score: np.ndarray = None,
):
    # pylint: disable=invalid-name
    # pylint: disable=comparison-with-callable
    """
    Advances in Financial Machine Learning, Snippet 7.4, page 110.

    Using the PurgedKFold Class.

    Function to run a cross-validation evaluation of the classifier using sample weights and a custom CV generator.
    Scores are computed using accuracy_score, probability_weighted_accuracy, log_loss and f1_score.

    Note: This function is different to the book in that it requires the user to pass through a CV object. The book
    will accept a None value as a default and then resort to using PurgedCV, this also meant that extra arguments had to
    be passed to the function. To correct this we have removed the default and require the user to pass a CV object to
    the function.

    Example:

    .. code-block:: python

        cv_gen = PurgedKFold(n_splits=n_splits, t1=t1, pct_embargo=pct_embargo)
        scores_array = ml_cross_val_scores_all(classifier, X, y, cv_gen, sample_weight_train=sample_train,
                                               sample_weight_score=sample_score, scoring=accuracy_score)

    :param classifier: (BaseEstimator) A scikit-learn Classifier object instance.
    :param X: (pd.DataFrame) The dataset of records to evaluate.
    :param y: (pd.Series) The labels corresponding to the X dataset.
    :param cv_gen: (BaseCrossValidator) Cross Validation generator object instance.
    :param sample_weight_train: (np.array) Sample weights used to train the model for each record in the dataset.
    :param sample_weight_score: (np.array) Sample weights used to evaluate the model quality.
    :return: (dict) The computed scores.
    """
    scoring_methods = [accuracy_score, probability_weighted_accuracy, log_loss, f1_score]
    ret_scores = {
        scoring.__name__ if scoring != log_loss else "neg_log_loss": []
        for scoring in scoring_methods
    }

    # If no sample_weight then broadcast a value of 1 to all samples (full weight).
    if sample_weight_train is None:
        sample_weight_train = np.ones((X.shape[0],))

    if sample_weight_score is None:
        sample_weight_score = np.ones((X.shape[0],))

    # Score model on KFolds
    for train, test in cv_gen.split(X=X, y=y):
        fit = classifier.fit(
            X=X.iloc[train, :],
            y=y.iloc[train],
            sample_weight=sample_weight_train[train],
        )
        prob = fit.predict_proba(X.iloc[test, :])
        pred = fit.predict(X.iloc[test, :])
        for method, scoring in zip(ret_scores.keys(), scoring_methods):
            if scoring in (accuracy_score, f1_score):
                score = scoring(y.iloc[test], pred, sample_weight=sample_weight_score[test])
            else:
                score = scoring(
                    y.iloc[test],
                    prob,
                    sample_weight=sample_weight_score[test],
                    labels=classifier.classes_,
                )
                if method == "neg_log_loss":
                    score *= -1
            ret_scores[method].append(score)

    for k, v in ret_scores.items():
        ret_scores[k] = np.array(v)

    return ret_scores


Экспериментальные результаты

Сравнение эффективности стратегий

Мы оценили две различные стратегии с взвешиванием выборок и без него, используя 10-fold CV:

  • Meta-labeled Bollinger Bands: традиционная стратегия возврата к среднему;
  • Meta-labeled MA_20_50 Crossover: классический трендовый подход.

Эффективность стратегии Bollinger Bands

Метрика Без взвешивания Взвешивание по уникальности Взвешивание по доходности
Accuracy 0.564 ± 0.044 0.584 ± 0.040 0.693 ± 0.020
PWA 0.563 ± 0.054 0.593 ± 0.044 0.697 ± 0.019
Отрицательный log-loss -0.688 ± 0.008 -0.682 ± 0.007 -0.631 ± 0.023
Precision 0.650 ± 0.019 0.658 ± 0.024 0.000 ± 0.000
Recall 0.616 ± 0.167 0.683 ± 0.145 0.000 ± 0.000
F1 Score 0.622 ± 0.091 0.663 ± 0.073 0.000 ± 0.000

Эффективность стратегии MA 20-50 Crossover

Метрика Без взвешивания Взвешивание по уникальности Взвешивание по доходности
Accuracy 0.589 ± 0.073 0.634 ± 0.068 0.473 ± 0.011
PWA 0.672 ± 0.101 0.740 ± 0.080 0.473 ± 0.011
Отрицательный log-loss -0.650 ± 0.037 -0.625 ± 0.036 -0.826 ± 0.018
Precision 0.298 ± 0.026 0.296 ± 0.029 0.473 ± 0.011
Recall 0.588 ± 0.125 0.530 ± 0.108 1.000 ± 0.000
F1 Score 0.388 ± 0.015 0.372 ± 0.018 0.642 ± 0.010

Ключевые выводы из результатов

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

Взвешивание по уникальности: надёжный вариант по умолчанию для meta-labeling

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

  • Стратегия Bollinger Bands: взвешивание по уникальности обеспечило сбалансированное улучшение эффективности. Accuracy выросла с 56,4% до 58,4%, но, что важнее, F1 score увеличился на существенные 6,7% — с 0,622 до 0,663. Это указывает на более качественный баланс между precision и recall, то есть модель стала лучше отфильтровывать ложные сигналы, одновременно выявляя реальные торговые возможности. Улучшение probability-weighted accuracy дополнительно подтверждает, что уверенность модели стала лучше калиброваться на уникальных, неизбыточных примерах.
  • Стратегия MA Crossover: преимущества оказались ещё более выраженными. Взвешивание по уникальности привело к росту accuracy на 7,5% — с 58,9% до 63,4% — и к заметному увеличению probability-weighted accuracy на 10,2% — с 67,2% до 74,0%. Хотя F1 score немного снизился, резкий рост метрики, взвешенной по уверенности критически важен для модели meta-labeling, где цель состоит в том, чтобы масштабировать позиции на основе вероятности успеха первичного сигнала.

Взвешивание по атрибуции доходности: предостерегающий пример

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

  • Стратегия Bollinger Bands: модель деградировала до тривиального классификатора. Precision, recall и F1 score упали до нуля, тогда как accuracy парадоксально выросла до 69,3%. Такой паттерн является классическим признаком модели, которая научилась всегда предсказывать класс большинства — вероятно, 0, то есть "не входить в сделку". Она переобучилась на величину прошлых доходностей, полностью утратив предсказательную способность для задачи классификации.
  • Стратегия MA Crossover: возник похожий, хотя и несколько иной, режим отказа. Модель достигла идеального recall 1,0 и 64,2% F1 score, но при accuracy всего 47,3%. Это говорит о том, что модель научилась почти без разбора предсказывать положительный класс — 1, то есть "входить в сделку", — захватывая все истинно положительные случаи, но одновременно генерируя огромное количество ложноположительных сигналов. Такое поведение неприемлемо для реальной торговой системы.

Провал атрибуции доходности подчёркивает, что одновременность и величина доходности — это разные понятия. Взвешивание только по доходностям искажает обучающий сигнал, заставляя модель преследовать прошлую прибыль вместо того, чтобы изучать обобщаемые паттерны из уникальных информационных событий. Величина доходности оказывается полезной, когда мы прогнозируем направленность — метки {1, -1}. Не забывайте, что meta-labeling направлен на улучшение нашей первичной модели за счёт фильтрации неверных прогнозов и создания независимой модели определения размера ставки, что делает его неподходящим местом для применения атрибуции доходности. Проведите собственные тесты и посмотрите, что получится.

Повсеместность проблемы одновременности

Значительные улучшения эффективности от взвешивания по уникальности как для стратегии возврата к среднему — Bollinger Bands, — так и для трендовой стратегии — MA Crossover — дают убедительные доказательства того, что одновременность меток является универсальной проблемой в финансовом машинном обучении. Это не крайний случай, а фундаментальная проблема утечки данных, которая смещает модели независимо от логики базовой стратегии. Для разработки устойчивых моделей её устранение не является опциональным.


Заключение

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

Метод взвешивания по уникальности стабильно улучшал эффективность модели, обеспечивая, чтобы влияние каждого наблюдения при обучении было пропорционально его уникальному информационному содержанию. Для стратегии Bollinger Bands он улучшил баланс precision-recall, выраженный через F1 score. Для стратегии MA Crossover он значительно повысил как стандартную, так и probability-weighted accuracy. В обоих случаях он уводил модель от изучения ложных паттернов, возникающих из временно избыточных данных.

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

Для практика эти выводы превращаются в чёткое руководство:

  1. Всегда учитывайте одновременность: предположение IID фундаментально неверно для финансовых временных рядов. Игнорирование одновременности меток приведёт к переобученным моделям и убыткам в реальной торговле.
  2. Внедряйте взвешивание по уникальности: описанный здесь метод, который рассчитывает среднюю уникальность каждой метки тройного барьера, является практичным и весьма эффективным решением. Этот метод стоит включать в стандартный пайплайн финансового ML.
  3. Избегайте наивного взвешивания по доходности: тщательно оценивайте, подходят ли взвешивания наблюдений, атрибутированные по доходности, для ваших моделей. Хотя доходности крайне важны для оценки эффективности стратегии, они могут быть вводящим в заблуждение прокси-показателем ценности наблюдения при обучении — в зависимости от того, что именно вы таргетируете.
  4. Валидируйте с помощью правильных метрик: как показано выше, accuracy сама по себе может быть обманчивой. Комбинация log-loss, F1 score и probability-weighted accuracy необходима для корректной диагностики эффективности и калибровки модели.

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

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


Вложения

Название файла Описание
bollinger_features.py Создаёт признаки на основе полос Боллинджера для моделей meta-labeling, включая признаки волатильности, технические индикаторы и признаки скользящих средних. Также содержит функции визуализации для построения полос Боллинджера с торговыми сигналами.
filters.py Реализует методы фильтрации событий, включая Symmetric CUSUM Filter и Z-Score Filter, для выявления значимых рыночных событий при triple-barrier labeling.
fractals.py Предоставляет комплексные инструменты фрактального анализа для определения точек рыночной структуры, валидации тренда и фильтрации whipsaw на основе торговых концепций Билла Вильямса.
ma_crossover_feature_engine.py Специализированная генерация признаков для forex-стратегий пересечения скользящих средних, включая анализ силы валют, признаки риск-среды и паттерны рыночной микроструктуры.
misc.py Содержит утилитарные функции для оптимизации данных, форматирования, логирования, мониторинга производительности и преобразования времени, используемые во всём пайплайне ML.
moving_averages.py Рассчитывает различия скользящих средних и сигналы пересечения для генерации признаков, с опциональным отбором признаков на основе корреляции.
multiprocess.py Предоставляет утилиты параллельной обработки для эффективных вычислений на нескольких CPU-ядрах, реализуя паттерны multiprocessing из AFML.
returns.py Рассчитывает различные признаки на основе доходностей, включая лагированные доходности, скользящие автокорреляции и статистики распределения доходностей.
signal_processing.py Преобразует сырые сигналы стратегии в непрерывные торговые позиции и временные метки входов, обрабатывая CUSUM-фильтрацию и персистентность сигналов.
strategies.py Определяет базовые и конкретные классы торговых стратегий, включая Bollinger Bands и Moving Average Crossover, для генерации сигналов.
time.py Генерирует временные признаки, включая циклическое кодирование, флаги торговых сессий и признаки рыночного тайминга forex для 24-часовых рынков.
trend_scanning.py Реализует методологию trend-scanning labeling, оценивает OLS-регрессии на нескольких окнах для выявления значимых трендов при классификации.
triple_barrier.py Основная реализация метода triple-barrier labeling с Numba-оптимизацией для производительности, включая вертикальные барьеры и поддержку meta-labeling.
volatility.py Предоставляет различные оценки волатильности, включая daily volatility, Parkinson, Garman-Klass и Yang-Zhang estimators для оценки риска.
attribution.py Реализует методы взвешивания выборок, включая атрибуцию доходности и коэффициенты временного затухания, для решения проблемы одновременности меток в финансовом машинном обучении с использованием параллельной обработки для эффективных вычислений.
concurrent.py Обрабатывает анализ одновременностей для triple-barrier events, включая подсчёт одновременностей и расчёт средней уникальности меток для работы с перекрывающимися периодами разметки.
optimized_attribution.py Numba-оптимизированная версия атрибуции доходности и временного затухания с повышением производительности в 5–10 раз, использующая JIT-компиляцию и векторизованные операции для более быстрого расчёта весов выборок.
optimized_concurrent.py Numba-оптимизированная версия анализа одновременностей с повышением производительности в 5–10 раз, использующая параллельную обработку и эффективные паттерны доступа к памяти для более быстрого расчёта уникальности.


Источники и дополнительная литература

Основной источник:
López de Prado, M. (2018): Advances in Financial Machine Learning. Wiley.

Связанные работы:

  • López de Prado, M. (2015): "The Future of Empirical Finance." The Journal of Portfolio Management.
  • López de Prado, M. (2020): Machine Learning for Asset Managers. Cambridge University Press.
  • Rao, C., P. Pathak and V. Koltchinskii (1997): "Bootstrap by sequential resampling." Journal of Statistical Planning and Inference, Vol. 64, No. 2, pp. 257–281.
  • King, G. and L. Zeng (2001): "Logistic Regression in Rare Events Data." Working paper, Harvard University. Available at https://gking.harvard.edu/files/0s.pdf.
  • Lo, A. (2017): Adaptive Markets, 1st ed. Princeton University Press.

Перевод с английского произведен MetaQuotes Ltd.
Оригинальная статья: https://www.mql5.com/en/articles/19850

Прикрепленные файлы |
strategies.py (4.34 KB)
fractals.py (16.45 KB)
misc.py (19.8 KB)
time.py (8.25 KB)
triple_barrier.py (18.88 KB)
trend_scanning.py (11.02 KB)
filters.py (8.39 KB)
returns.py (6.27 KB)
volatility.py (5.38 KB)
attribution.py (5.88 KB)
concurrent.py (4.96 KB)
Кодекс рыночных состояний в MQL5 (Часть 1): Побитовое обучение на примере Nvidia Кодекс рыночных состояний в MQL5 (Часть 1): Побитовое обучение на примере Nvidia
Мы начинаем новую серию статей, которая развивает наши предыдущие наработки, изложенные в серии о MQL5 Wizard, и продвигает их дальше по мере усиления нашего подхода к системной торговле и тестированию стратегий. В этой новой серии мы сосредоточимся на советниках, запрограммированных на удержание только одного типа позиций — преимущественно длинных. Сосредоточение на одном направлении торговли может упростить анализ, снизить сложность стратегии и дать важные наблюдения, особенно при работе с активами за пределами Forex. Поэтому в этой серии мы исследуем, эффективен ли такой подход для акций и других невалютных активов, где long-only-системы часто хорошо согласуются с подходом smart money и стратегиями институциональных участников.
Разработка инструментария для анализа Price Action (Часть 41): Создание советника для статистического анализа ценовых уровней на MQL5 Разработка инструментария для анализа Price Action (Часть 41): Создание советника для статистического анализа ценовых уровней на MQL5
Статистика всегда лежала в основе финансового анализа. По определению статистика – это дисциплина, которая собирает, анализирует, интерпретирует и представляет данные в осмысленном виде. Теперь представьте, что тот же подход применяется к свечам – необработанная ценовая динамика преобразуется в измеримые показатели. Насколько полезно было бы знать для заданного периода центральную тенденцию, разброс и распределение поведения рынка? В этой статье мы покажем именно такой подход и разберем, как статистические методы превращают свечные данные в четкие, практические сигналы.
Двумерные копулы в MQL5 (Часть 2): Реализация архимедовых копул в MQL5 Двумерные копулы в MQL5 (Часть 2): Реализация архимедовых копул в MQL5
Во второй части серии мы рассматриваем свойства двумерных архимедовых копул и их реализацию в MQL5. Мы также изучаем применение копул для разработки простой стратегии парного трейдинга.
Двумерные копулы в MQL5 (Часть 1): Реализация гауссовой копулы и t-копулы Стьюдента для моделирования зависимостей Двумерные копулы в MQL5 (Часть 1): Реализация гауссовой копулы и t-копулы Стьюдента для моделирования зависимостей
Это первая часть серии статей, посвящённых реализации двумерных копул в MQL5. В статье представлен код, реализующий гауссову копулу и t-копулу Стьюдента. Также рассматриваются основы статистических копул и связанные с ними темы. Код основан на Python-пакете ArbitrageLab от Hudson and Thames.