Архитектура системы машинного обучения в MetaTrader5 (Часть 5): Последовательный бутстреппинг— устранение смещения меток и повышение доходности
Введение
В этой статье представлен метод последовательного бутстреппинга— строго обоснованный метод выборки, который устраняет проблему конкурентности/перекрытия наблюдений на уровне самого механизма выборки. Вместо того чтобы исправлять избыточность после завершения выборки, последовательный бутстреппинг активно предотвращает ее в процессе формирования выборки. Динамически корректируя вероятности отбора на основе временных перекрытий, этот метод создает бутстреп-выборки с максимально независимыми наблюдениями.
Мы покажем, как:
- Понять фундаментальные ограничения стандартного бутстреп-метода в финансовом контексте.
- Реализовать алгоритм последовательного бутстреппинга с нуля.
- Проверить его эффективность с помощью симуляций методом Монте-Карло.
- Интегрировать его в полноценный конвейер машинного обучения для финансов.
- Оценить улучшение производительности на реальных торговых стратегиях.
Необходимые условия
Данная статья предполагает знакомство с проблемой одновременности меток в финансовом машинном обучении, а также с техниками метод тройного барьера, которые обсуждались ранее в этой серии. Для полноценного понимания материала также необходимо хорошее практическое знание библиотек машинного обучения на Python.
Весь исходный код, относящийся к этой серии статей, можно найти в моем репозитории на GitHub.
Почему бутстреп работает в традиционной статистике
Метод бутстрепа, представленный Брэдли Эфроном в 1979 году, является одним из самых мощных инструментов статистического вывода. Его элегантность заключается в простоте: чтобы оценить выборочное распределение статистики, нужно многократно производить повторную выборку из имеющихся данных с возвращением и вычислять статистику для каждой такой псевдовыборки.
Этот метод блестяще работает, когда точки данных независимы и одинаково распределены (i.i.d.). Каждое наблюдение в медицинском исследовании, сельскохозяйственном эксперименте или выборке контроля качества на производстве, как правило, представляет собой независимое событие. Анализы крови разных пациентов содержат независимую информацию. Урожайность с разных делянок отражает независимые условия выращивания.
Правило 2/3: скрытая особенность, а не ошибка
У стандартного бутстрепа есть одно интересное математическое свойство, которое большинство практикующих упускают из виду. Когда вы осуществляете выборку I раз с возвращением из I наблюдений, каждая бутстреп-выборка будет содержать примерно 2/3 исходных наблюдений.
Давайте разберемся, почему так происходит.
Простой мысленный эксперимент
Представьте, что у вас есть мешок со 100 шарами, пронумерованными от 1 до 100. Вы собираетесь:
- Случайным образом вытащить один шар
- Записать его номер
- Положить шар обратно (это ключевой момент!)
- Повторить эти действия 100 раз
Вопрос: После 100 извлечений, сколько различных шаров вы увидели хотя бы один раз?
Ваша интуиция может подсказывать: "Я сделал 100 извлечений, значит, наверное, все 100?" Но из-за того, что вы возвращаете каждый шар обратно, некоторые шары будут выбраны несколько раз, в то время как другие не попадутся вам ни разу.
Математика
Рассмотрим один конкретный шар, скажем, шар № 42.
При каждом извлечении:
- Вероятность вытащить шар #42 = 1/100
- Вероятность не вытащить шар #42 = 99/100
После 100 извлечений:
- Вероятность того, что вы НИ РАЗУ не вытащили шар #42 = (99/100)100 ≈ 0.366
Это означает, что существует примерно 63,4% вероятность того, что шар #42 был вытащен хотя бы один раз.
Эта закономерность сохраняется независимо от масштаба:
| Количество элементов | Извлечения | Вероятность того, что конкретный элемент будет отобран хотя бы раз |
|---|---|---|
| 10 | 10 | (9/10)10 ≈ 0.651 |
| 100 | 100 | (99/100)100 ≈ 0.634 |
| 1,000 | 1,000 | (999/1000)1000 ≈ 0.632 |
| 10,000 | 10,000 | (9999/10000)10000 ≈ 0.632 |
Эта дробь стремится к 1 - e-1 ≈ 0.632(где e ≈ 2,71828 — число Эйлера).
Почему именно это число? Константа естественного роста
Точное значение равно 1- e-1, и здесь появляется e, потому что мы делим наши шансы на всё более мелкие части по мере роста I. Это та же математическая закономерность, которая управляет непрерывным начислением сложных процентов.
Простое правило для запоминания:
Когда вы делаете выборку С ВОЗВРАЩЕНИЕМ, совершая столько же извлечений, сколько у вас элементов, вы увидите около 63% элементов(примерно 2/3).
Это означает, что стандартный бутстреп в каждой итерации естественным образом оставляет неотобранными около 37% ваших данных. В традиционной статистике это совершенно нормально — это даже помогает в оценке дисперсии. Но в финансах это приводит к катастрофическому взаимодействию с одновременностью меток.
Почему это становится катастрофой в финансах
Правило 2/3 предполагает, что каждое наблюдение содержит независимую информацию. В финансовом машинном обучении с использованием тройного барьерного разметки это предположение полностью рушится из-за временного перекрытия.
Вспомним аналогию с образцами крови из части 3: представьте, что кто-то в вашей лаборатории проливает кровь из каждой пробирки в следующие девять пробирок справа. Пробирка 10 содержит кровь пациента 10, но также кровь пациентов с 1 по 9. Теперь вы берёте выборки из этих «загрязнённых» пробирок с возвращением.
Нарастающая катастрофа:
- Стандартный бутстреп выбирает лишь ~63% наблюдений
- Каждое выбранное наблюдение временно перекрывается с другими
- Эффективное количество независимой информации намного меньше 63%
- Модели многократно изучают одни и те же закономерности в пределах одной бутстреп-выборки
- Оценки дисперсии становятся ненадёжными, что сводит на нет смысл бутстрепа
Рассмотрим конкретный пример с тройной барьерной разметкой:
Сценарий: 100 торговых наблюдений в период высокой волатильности
- Каждая сделка длится в среднем 4 часа (время выхода по тройному барьеру)
- Новые сделки открываются каждые 15 минут
- Это создаёт примерно 16 одновременных сделок в любой момент времени
Результат стандартного бутстрепа:
- Выбирается около 63 наблюдений
- Из-за перекрытия эффективная уникальная информация ≈ 63/16 ≈ 4 действительно независимых события
- Вы фактически обучаетесь на эквиваленте 4 независимых наблюдений, а не 63!
Именно поэтому модели, обученные с использованием стандартного бутстрепа в финансовом контексте, демонстрируют:
- Искусственно низкую ошибку на обучении (один и тот же паттерн выучен 16 раз)
- Высокую дисперсию между бутстреп-итерациями (случайно получаются разные "копии" одних и тех же событий)
- Плохое качество на вневыборочных данных (реальная частота паттернов значительно ниже)
Последовательный бутстреп: Решение
Последовательный бутстреп принципиально переосмысливает процесс выборки. Вместо того чтобы выбирать наблюдения с равной вероятностью, он динамически корректирует вероятности, отдавая предпочтение тем наблюдениям, которые добавляют уникальную информацию к текущей выборке.
Концептуальная основа
Ключевая идея: ценность каждого наблюдения для бутстреп-выборки зависит от того, что уже было выбрано.
Если выборка уже содержит наблюдения, охватывающие понедельник с 9:00 до 11:00, то ещё одно наблюдение с понедельника с 10:00 до 12:00 добавляет относительно мало новой информации. А вот наблюдение со вторника днём, напротив, будет весьма ценным.
Последовательный бутстреп реализует эту интуицию через три шага, повторяемые при каждом выборе:
- Оценка текущего состояния: определить, какие временные периоды уже представлены в выборке
- Расчёт уникальности: для каждого оставшегося наблюдения вычислить, сколько уникальной информации оно добавит
- Корректировка вероятностей: выбрать следующее наблюдение с вероятностью, пропорциональной его уникальности
Математическая формализация
Давайте формализуем эту интуицию. Рассмотрим набор меток {y[i]} i = 1, 2, 3, где:
- метка y[1] является функцией доходности r[0,3]
- метка y[2] является функцией доходности r[2,4]
- метка y[3] является функцией доходности r[4,6]
Строки матрицы соответствуют индексам доходностей, использованных для разметки набора данных, а столбцы соответствуют наблюдениям. Перекрытие наблюдений описывается следующей индикаторной матрицей:
| Время | Набл. 1 | Набл. 2 | Набл. 3 |
|---|---|---|---|
| 1 | 1 | 0 | 0 |
| 2 | 1 | 0 | 0 |
| 3 | 1 | 1 | 0 |
| 4 | 0 | 1 | 0 |
| 5 | 0 | 0 | 1 |
| 6 | 0 | 0 | 1 |
Средняя уникальность ū[i] вычисляется как:
ū[i] = (1/L[i]) × Σ(t ∈ T[i]) [1 / c[t]]
Где:
- L[i] = количество временных периодов, которые охватывает наблюдение i
- T[i] = множество временных периодов, в которых наблюдениеi активно
- c[t] = количество наблюдений, активных в момент времени t, включая все наблюдения, уже выбранные в выборку, а также кандидатi
Вероятность выбора наблюдения iопределяется как:
P(select i) = ū[i] / Σ(j ∈ candidates) ū[j]
Это гарантирует:
- Наблюдения без перекрытий получают наибольшую вероятность
- Наблюдения, сильно перекрывающиеся с текущей выборкой, получают наименьшую вероятность
- Сумма вероятностей всегда равна 1 (корректное распределение вероятностей)
Числовой пример с пошаговым разбором
Давайте разберём пример, используя приведённую выше индикаторную матрицу.
Шаг 1: Первый выбор
Изначально ни одно наблюдение не выбрано, поэтому все имеют равную вероятность:
P(Набл. 1) = P(Набл. 2) = P(Набл. 3) = 1/3 ≈ 33.3%
Результат: случайно выбрано наблюдение 2
Текущая выборка: φ¹ = {2}
Шаг 2: Второй выбор — расчёт уникальности
Теперь нужно вычислить уникальность каждого наблюдения с учётом того, что наблюдение 2 уже включено в выборку.
Наблюдение 1:
- Активно во времена: {1, 2, 3}
- Время 1: c[1] = 1 (только набл. 1), уникальность = 1/1 = 1.0
- Время 2: c[2] = 1 (только набл. 1), уникальность = 1/1 = 1.0
- Время 3: c[3] = 2 (набл. 1 + набл. 2), уникальность = 1/2 = 0.5
- Средняя уникальность: (1.0 + 1.0 + 0.5)/3 = 2.5/3 = 5/6 ≈ 0.833
Наблюдение 2:
- Активно во времена: {3, 4}
- Время 3: c[3] = 2 (набл. 2 + само себя), уникальность = 1/2 = 0.5
- Время 4: c[4] = 1 (только набл. 2), уникальность = 1/1 = 1.0
- Средняя уникальность: (0.5 + 1.0)/2 = 1.5/2 = 3/6 = 0.5
Наблюдение 3:
- Активно во времена: {5, 6}
- Время 5: c[5] = 1 (только набл. 3), уникальность = 1/1 = 1.0
- Время 6: c[6] = 1 (только набл. 3), уникальность = 1/1 = 1.0
- Средняя уникальность: (1.0 + 1.0)/2 = 2.0/2 = 6/6 = 1.0
Расчёт вероятностей:
- Сумма уникальностей: 5/6 + 3/6 + 6/6 = 14/6
- P(Набл. 1) = (5/6) / (14/6) = 5/14 ≈ 35.7%
- P(Набл. 2) = (3/6) / (14/6) = 3/14 ≈ 21.4% ← минимальная (уже выбрано)
- P(Набл. 3) = (6/6) / (14/6) = 6/14 ≈ 42.9% ← максимальная (нет перекрытия)
Результат: выбрано наблюдение 3
Текущая выборка: φ¹ = {2,3}
Шаг 3: Третий выбор — расчёт уникальности
Структура перекрытий не изменилась (наблюдения 2 и 3 не пересекаются), поэтому вероятности остаются теми же:
- P(Набл. 1) = 5/14 ≈ 35.7%
- P(Набл. 2) = 3/14 ≈ 21.4%
- P(Набл. 3) = 6/14 ≈ 42.9%
Итоговое распределение вероятностей:
| Выбор | Наблюдение 1 | Наблюдение 2 | Наблюдение 3 | Выбрано |
|---|---|---|---|---|
| 1 | 1/3 (33.3%) | 1/3 (33.3%) | 1/3 (33.3%) | 2 |
| 2 | 5/14 (35.7%) | 3/14 (21.4%) | 6/14 (42.9%) | 3 |
| 3 | 5/14 (35.7%) | 3/14 (21.4%) | 6/14 (42.9%) | ? |
Ключевые наблюдения из этого примера:
- Наименьшая вероятность у ранее выбранных наблюдений (наблюдение 2 падает с 33.3% до 21.4%)
- Наибольшая вероятность у наблюдений без перекрытий (наблюдение 3 возрастает до 42.9%)
- Частичное перекрытие получает промежуточный вес (наблюдение 1 немного увеличивается до 35.7%)
- Метод эффективно снижает избыточность, одновременно сохраняя разнообразие выборки
Реализация
Основной алгоритм
Для реализации требуются две ключевые функции: одна для построения индикаторной матрицы, и одна — для выполнения последовательной выборки.
Функция 1: Построение индикаторной матрицы
def get_ind_matrix(bar_index, t1): """ Build an indicator matrix showing which observations are active at each time. :param bar_index: (pd.Index) Complete time index (all bars) :param t1: (pd.Series) End time for each observation (index = start time, value = end time) :return: (pd.DataFrame) Indicator matrix where ind_matrix[t, i] = 1 if obs i is active at time t """ ind_matrix = pd.DataFrame(0, index=bar_index, columns=range(t1.shape[0])) for i, (t_in, t_out) in enumerate(t1.items()): # Mark all times from t_in to t_out as active for observation i ind_matrix.loc[t_in:t_out, i] = 1.0 return ind_matrix
Функция 2: Расчёт средней уникальности
def get_avg_uniqueness(ind_matrix): """ Calculate average uniqueness for each observation. Average uniqueness of observation i = mean of (1/c[t]) across all times t where i is active, where c[t] is the number of observations active at time t. :param ind_matrix: (pd.DataFrame) Indicator matrix from get_ind_matrix :return: (pd.Series) Average uniqueness for each observation """ # Count how many observations are active at each time (row sums) concurrency = ind_matrix.sum(axis=1) # Calculate uniqueness: 1/concurrency for each observation at each time # Replace concurrency with NaN where ind_matrix is 0 (observation not active) uniqueness = ind_matrix.div(concurrency, axis=0) # Average uniqueness across all times where observation is active avg_uniqueness = uniqueness[uniqueness > 0].mean(axis=0) return avg_uniqueness
Функция 3: Последовательный бутстреп-сэмплер
def seq_bootstrap(ind_matrix, sample_length=None): """ Generate a bootstrap sample using sequential bootstrap method. :param ind_matrix: (pd.DataFrame) Indicator matrix from get_ind_matrix :param sample_length: (int) Length of bootstrap sample. If None, uses len(ind_matrix.columns) :return: (list) Indices of selected observations """ if sample_length is None: sample_length = ind_matrix.shape[1] phi = [] # Bootstrap sample (list of selected observation indices) while len(phi) < sample_length: # Calculate average uniqueness for each observation avg_u = pd.Series(dtype=float) for i in ind_matrix.columns: # Create temporary indicator matrix with current sample + candidate i ind_matrix_temp = ind_matrix[phi + [i]] avg_u.loc[i] = get_avg_uniqueness(ind_matrix_temp).iloc[-1] # Convert uniqueness to probabilities prob = avg_u / avg_u.sum() # Draw next observation according to probabilities selected = np.random.choice(ind_matrix.columns, p=prob) phi.append(selected) return phi
Соображения о вычислительной эффективности
Представленная выше реализация концептуально понятна, но требует больших вычислительных затрат для больших наборов данных. На каждой итерации заново рассчитывается уникальность для всех оставшихся наблюдений, что приводит к сложности O(n³).
Для производственных систем критически важны следующие оптимизации:
- Инкрементальные обновления: вместо пересчёта всей индикаторной матрицы можно поддерживать текущий счётчик одновременных событий (concurrency count), который обновляется при добавлении наблюдений в выборку.
- Предварительные вычисления: попарные пересечения (overlaps) можно вычислить один раз в начале, а затем использовать простые обращения к ним во время сэмплирования.
- Параллелизация: несколько бутстреп-выборок могут быть сгенерированы независимо друг от друга параллельно.
- Операции с разреженными матрицами: временные ряды в финансах часто характеризуются ограниченным количеством одновременных событий; использование разреженных матриц позволяет эффективно использовать эту особенность.
Ожидаемые Результаты
На рисунке 1 показана гистограмма уникальности для стандартных бутстреп-выборок (слева) и для последовательных бутстреп-выборок (справа). Медиана средней уникальности для стандартного метода составляет 0,6, а для последовательного метода — 0,7. Дисперсионный анализ (ANOVA) различия средних даёт исчезающе малую вероятность. Статистически говоря, выборки, полученные с помощью последовательного бутстрепа, имеют ожидаемую уникальность, превышающую таковую для стандартного бутстрепа, при любом разумном уровне доверия. Если вы хотите провести собственный Монте-Карло-симуляцию, обратитесь к приложенному файлу bootstrap_mc.py.

Рисунок 1: Эксперимент Монте-Карло стандартного и последовательного бутстрепа
Оптимизированная реализация
Этот раздел объясняет, почему оптимизированная реализация (сплющенные индексы + ускоренная с помощью Numba выборки) эффективнее стандартного подхода с индикаторной матрицей, и почему каждая функция была выбрана именно так. Эмпирические сравнения по памяти и вычислительной сложности, приведённые в статье, подтверждают эти утверждения.
Шаг 1: get_active_indices — разреженное отображение против плотной индикаторной матрицы
Назначение: преобразовать времена начала и окончания событий в списки индексов баров (временных шагов), которые покрывает каждое наблюдение.
Почему это лучше: хранятся только ненулевые элементы (то есть точные индексы баров, к которым относится каждое наблюдение), вместо выделения плотной матрицы размером n × T. Это снижает потребление памяти с O(n·T) до O(суммы длин событий), что на практике часто близко к линейной зависимости от n при разреженных данных.
Практическое преимущество: существенно уменьшается объём используемой памяти и улучшается работа кэша при обработке покрытия наблюдений; именно это даёт выигрыш в сжатии данных и снижении потребления памяти, показанный в оптимизированном анализе.
def get_active_indices(samples_info_sets, price_bars_index): """ Build an indicator mapping from each sample to the bar indices it influences. Args: samples_info_sets (pd.Series): Triple-barrier events (t1) returned by labeling.get_events. Index: start times (t0) as pd.DatetimeIndex. Values: end times (t1) as pd.Timestamp (or NaT for open events). price_bars_index (pd.DatetimeIndex or array-like): Sorted bar timestamps (pd.DatetimeIndex or array-like). Will be converted to np.int64 timestamps for internal processing. Returns: dict: Standard Python dictionary mapping sample_id (int) to a numpy.ndarray of bar indices (dtype=int64). Example: {0: array([0,1,2], dtype=int64), 1: array([], dtype=int64), ...} """ t0 = samples_info_sets.index t1 = samples_info_sets.values n = len(samples_info_sets) active_indices = {} # precompute searchsorted positions to restrict scanning range starts = np.searchsorted(price_bars_index, t0, side="left") ends = np.searchsorted(price_bars_index, t1, side="right") # exclusive for sample_id in range(n): s = starts[sample_id] e = ends[sample_id] if e > s: active_indices[sample_id] = np.arange(s, e, dtype=int) else: active_indices[sample_id] = np.empty(0, dtype=int) return active_indices
Шаг 2: pack_active_indices — непрерывное (сплющенное) представление для высокой производительности
Назначение: преобразовать dict/list-of-arrays в flat_indices, offsets, lengths, и sample_ids.
Почему это лучше: непрерывные массивы устраняют накладные расходы Python-объектов и позволяют выполнять плотные линейные проходы по памяти. Массив offsets обеспечивает доступ к срезу каждого наблюдения за O(1), без необходимости обращаться к спискам на каждой итерации. Такой переход от "рваной" структуры к плоской является необходимым условием для эффективной работы с Numba/JIT.
Практическое преимущество: Numba может проходить по памяти последовательно, что улучшает предвыборку данных процессором (CPU prefetching) и снижает накладные расходы интерпретатора по сравнению с многократными операциями на уровне Python или использованием отдельных массивов для каждого наблюдения.
def pack_active_indices(active_indices): """ Convert dict/list-of-arrays active_indices into flattened arrays and offsets. Args: active_indices (dict or list): mapping sample_id -> 1D ndarray of bar indices Returns: flat_indices (ndarray int64): concatenated bar indices for all samples offsets (ndarray int64): start index in flat_indices for each sample (len = n+1) lengths (ndarray int64): number of indices per sample (len = n) sample_ids (list): list of sample ids in the order used to pack data """ # Preserve sample id ordering to allow mapping between chosen index and original id if isinstance(active_indices, dict): sample_ids = list(active_indices.keys()) values = [active_indices[sid] for sid in sample_ids] else: # assume list-like ordered by sample id 0..n-1 sample_ids = list(range(len(active_indices))) values = list(active_indices) lengths = np.array([v.size for v in values], dtype=np.int64) offsets = np.empty(len(values) + 1, dtype=np.int64) offsets[0] = 0 offsets[1:] = np.cumsum(lengths) total = int(offsets[-1]) if total == 0: flat_indices = np.empty(0, dtype=np.int64) else: flat_indices = np.empty(total, dtype=np.int64) pos = 0 for v in values: l = v.size if l: flat_indices[pos : pos + l] = v pos += l return flat_indices, offsets, lengths, sample_ids
Шаг 3: _compute_scores_flat / _normalize_to_prob — локальный инкрементальный расчёт оценок
Назначение: вычислять оценки (scores) для каждого наблюдения по формуле score = (1 / (1 + concurrency)).mean(), а затем нормализовать их в вектор вероятностей.
Почему это лучше: оценка рассчитывается только по тем барам, которые действительно покрывает наблюдение (через его срез в flat_indices), с учётом текущего уровня перекрытия (concurrency). Стоимость вычислений для одного наблюдения пропорциональна длине события k, а не всей временной шкале T. Это даёт сложность O(n-k) за полный проход вместо O(n-T).
Практическое преимущество: численная устойчивость достигается за счёт введения малого значения eps и детерминированной нормализации, что предотвращает случаи с нулевой суммой и делает распределение вероятностей устойчивым даже при росте перекрытий (concurrency).
@njit def _compute_scores_flat(flat_indices, offsets, lengths, concurrency): """ Compute average uniqueness for each sample using flattened indices. This follows de Prado's approach: for each bar in a sample, compute uniqueness as 1/(c+1), then average across all bars in that sample. Args: flat_indices (ndarray int64): concatenated indices offsets (ndarray int64): start positions (len = n+1) lengths (ndarray int64): counts per sample concurrency (ndarray int64): current concurrency counts per bar Returns: scores (ndarray float64): average uniqueness per sample """ n = offsets.shape[0] - 1 scores = np.empty(n, dtype=np.float64) for i in range(n): s = offsets[i] e = offsets[i + 1] length = lengths[i] if length == 0: # If a sample covers no bars, assign zero average uniqueness scores[i] = 0.0 else: # Compute uniqueness = 1/(c+1) for each bar, then average sum_uniqueness = 0.0 for k in range(s, e): bar = flat_indices[k] c = concurrency[bar] uniqueness = 1.0 / (c + 1.0) sum_uniqueness += uniqueness avg_uniqueness = sum_uniqueness / length scores[i] = avg_uniqueness return scores
@njit def _normalize_to_prob(scores): """ Normalize non-negative scores to a probability vector. If all zero, return uniform. """ n = scores.shape[0] total = 0.0 for i in range(n): total += scores[i] prob = np.empty(n, dtype=np.float64) if total == 0.0: # fallback to uniform distribution uni = 1.0 / n for i in range(n): prob[i] = uni else: for i in range(n): prob[i] = scores[i] / total return prob
Шаг 4: _increment_concurrency_flat — обновление перекрытий (concurrency) “на месте”
Назначение: увеличивать concurrency[bar] для каждого бара, который покрывает выбранное наблюдение.
Почему это лучше: обновляются только затронутые бары, вместо повторного сканирования или пересчёта больших массивов. В подходе с плотной матрицей часто требуется пересчитывать суммы по строкам или создавать временные структуры; здесь же все обновления локализованы и имеют сложность O(k).
Практическое преимущество: дешёвые инкрементальные обновления позволяют сэмплеру адаптироваться “на лету” после каждого выбора без дорогостоящих глобальных пересчётов, что делает выполнение большого количества бутстреп-итераций значительно более эффективным.
@njit def _increment_concurrency_flat(flat_indices, offsets, chosen, concurrency): """ Increment concurrency for the bars covered by sample `chosen`. """ s = offsets[chosen] e = offsets[chosen + 1] for k in range(s, e): bar = flat_indices[k] concurrency[bar] += 1
Шаг 5: _seq_bootstrap_loop и seq_bootstrap_optimized — полное ускорение через Numba с воспроизводимым ГСЧ
Назначение: выполнить полный цикл последовательного бутстрепа внутри Numba, используя заранее сгенерированные случайные числа (uniform) из Python для воспроизводимости генератора случайных чисел (RNG), а также сплющенную структуру данных для эффективности работы с памятью.
Почему это лучше:
- Устраняет накладные расходы Python на каждой итерации: расчёт оценок, выбор по функции распределения (CDF) и обновление concurrency выполняются внутри JIT-компилируемой функции, что избавляет от дорогостоящих переключений контекста интерпретатора.
- Сохраняет воспроизводимость: генерация случайных чисел управляется в Python (через NumPy RandomState), а сами значения передаются в JIT-цикл — это даёт и контроль над seed, и высокую скорость выполнения.
- Обеспечивает сложность O(n-k) с малыми константами: в отличие от стандартного алгоритма, который требует повторных пересчётов индикаторной матрицы и может доходить до O(n²) или хуже.
Практическое преимущество: комбинация сплющенного представления данных и полного JIT-ускорения даёт значительный выигрыш в скорости и использовании памяти, позволяя применять последовательный бутстреп к десяткам и сотням тысяч наблюдений в промышленных задачах.
@njit def _choose_index_from_cdf(prob, u): """ Convert a uniform random number u in [0,1) to an index using the cumulative distribution. This avoids calling numpy.choice inside numba and is efficient. """ n = prob.shape[0] cum = 0.0 for i in range(n): cum += prob[i] if u < cum: return i # numerical fallback: return last index return n - 1
@njit def _seq_bootstrap_loop(flat_indices, offsets, lengths, concurrency, uniforms): """ Njitted sequential bootstrap loop. Args: flat_indices, offsets, lengths: flattened index layout concurrency (ndarray int64): initial concurrency vector (will be mutated) uniforms (ndarray float64): pre-drawn uniform random numbers in [0,1), length = sample_length Returns: chosen_indices (ndarray int64): sequence of chosen sample indices (positions in packed order) """ sample_length = uniforms.shape[0] chosen_indices = np.empty(sample_length, dtype=np.int64) for it in range(sample_length): # compute scores and probabilities given current concurrency scores = _compute_scores_flat(flat_indices, offsets, lengths, concurrency) prob = _normalize_to_prob(scores) # map uniform to a sample index u = uniforms[it] idx = _choose_index_from_cdf(prob, u) chosen_indices[it] = idx # update concurrency for selected sample _increment_concurrency_flat(flat_indices, offsets, idx, concurrency) return chosen_indices
def seq_bootstrap_optimized(active_indices, sample_length=None, random_seed=None): """ End-to-end sequential bootstrap using flattened arrays + Numba. Implements the sequential bootstrap as described in de Prado's "Advances in Financial Machine Learning" Chapter 4: average uniqueness per sample where uniqueness per bar is 1/(concurrency+1). Args: active_indices (dict or list): mapping sample id -> ndarray of bar indices sample_length (int or None): requested number of draws; defaults to number of samples random_seed (int, RandomState, or None): seed controlling the pre-drawn uniforms Returns: phi (list): list of chosen original sample ids (length = sample_length) """ # Pack into contiguous arrays and keep mapping from packed index -> original sample id flat_indices, offsets, lengths, sample_ids = pack_active_indices(active_indices) n_samples = offsets.shape[0] - 1 if sample_length is None: sample_length = n_samples # Concurrency vector length: bars are indices into price-bar positions. # When there are no bars (flat_indices empty), create an empty concurrency of length 0. if flat_indices.size == 0: T = 0 else: # max bar index + 1 (bars are zero-based indices) T = int(flat_indices.max()) + 1 concurrency = np.zeros(T, dtype=np.int64) # Prepare reproducible uniforms. Accept either integer seed or RandomState. if random_seed is None: rng = np.random.RandomState() elif isinstance(random_seed, np.random.RandomState): rng = random_seed else: try: rng = np.random.RandomState(int(random_seed)) except (ValueError, TypeError): rng = np.random.RandomState() # Pre-draw uniforms in Python and pass them into njit function (numba cannot accept RandomState) uniforms = rng.random_sample(sample_length).astype(np.float64) # Run njit loop (this mutates concurrency but we don't need concurrency afterwards) chosen_packed = _seq_bootstrap_loop(flat_indices, offsets, lengths, concurrency, uniforms) # Map packed indices back to original sample ids phi = [sample_ids[int(i)] for i in chosen_packed.tolist()] return phi
Сложность и практические аспекты внедрения
- Память: оптимизированный подход снижает рост потребления памяти с квадратичного до почти линейного по n, превращая ранее неразрешимые по ресурсам задачи в вполне управляемые.
- Время: стоимость одной выборки становится пропорциональной средней длине события k, а не полной временной шкале T. Сложность одной итерации — O(n-k), что даёт общую сложность O(n²·k) для генерации n выборок. Это значительно лучше, чем O(n³) или хуже в наивных реализациях с индикаторной матрицей, особенно когда k << n.
- Инженерные аспекты: паттерн "сплющенные данные + njit" открывает возможности для дальнейших оптимизаций: (использование int32 для индексов (где это безопасно), параллелизацию процесса выборки, предварительное вычисление повторяющихся компонентов оценки простая интеграция в пайплайн, начиная с labeling.get_events и далее в оптимизированный сэмплер.)
Анализ эффективности памяти: оптимизированный vs стандартный подход
Сравнение потребления памяти
Данные по потреблению памяти демонстрируют разительные различия между стандартной и оптимизированной реализациями:
| Размер выборки | Стандартный | Оптимизированный | Снижение памяти | Коэффициент сжатия |
|---|---|---|---|---|
| 500 | 7.19 MB | 0.02 MB | 99.7% | 359:1 |
| 1,000 | 23.75 MB | 0.04 MB | 99.8% | 594:1 |
| 2,000 | 93.65 MB | 0.07 MB | 99.9% | 1,338:1 |
| 4,000 | 412.23 MB | 0.14 MB | 99.97% | 2,944:1 |
| 8,000 | 1,237.82 MB | 0.28 MB | 99.98% | 4,421:1 |
Математический анализ характера роста
Стандартная реализация (квадратичный рост)
Стандартная реализация демонстрирует сложность по памяти O(n²):
Memory(n) ≈ 0.0000188 × n² (MB) - При n = 8,000: прогноз ≈ 1,203 МБ vs фактически ≈ 1,238 МБ (точность 97%)
- Удвоение числа наблюдений → увеличение памяти в 4 раза (что соответствует квадратичному росту)
Оптимизированная реализация (линейный рост)
Оптимизированная реализация показывает сложность по памяти O(n):
Memory(n) ≈ 0.000035 × n (MB) - При n = 8,000: прогноз ≈ 0.28 МБ vs фактически ≈ 0.28 МБ (точное совпадение)
- Удвоение числа наблюдений → увеличение памяти в 2 раза (что соответствует линейному росту)
Практические последствия для финансового машинного обучения
Ограничения масштабируемости
Стандартная реализация
- n = 50,000 → ~47 ГБ (непрактично)
- n = 100,000 → ~188 ГБ (фактически невозможно)
- Максимально допустимо: ~15,000 наблюдений
Оптимизированная реализация
- n = 50,000 → ~1.75 МБ (незначительно)
- n = 100,000 → ~3.5 МБ (легко обрабатывается)
- Максимально допустимо: миллионы наблюдений
Сценарии применения в реальных условиях
# Typical financial dataset scenarios scenarios = { "Intraday Trading": { "samples": 50_000, # 2 years of 5-minute bars "standard_memory": "47 GB", "optimized_memory": "1.75 MB", "feasible": "Only with optimized" }, "Multi-Asset Portfolio": { "samples": 200_000, # 100 instruments × 2,000 bars "standard_memory": "752 GB", "optimized_memory": "7 MB", "feasible": "Only with optimized" }, "Research Backtesting": { "samples": 1_000_000, # Comprehensive market analysis "standard_memory": "18.8 TB", "optimized_memory": "35 MB", "feasible": "Only with optimized" } }
Технические аспекты архитектуры
Почему такое существенное различие?
Стандартная реализация (get_ind_matrix):
# Creates dense n × n matrix (O(n²) memory) ind_matrix = np.zeros((len(bar_index), len(label_endtime)), dtype=np.int8) for sample_num, label_array in enumerate(tokenized_endtimes): ind_mat[label_index:label_endtime+1, sample_num] = 1 # Fills entire ranges
Оптимизированная реализация (precompute_active_indices):
# Stores only active indices (O(k×n) memory, where k << n) active_indices = {} for sample_id in range(n_samples): mask = (price_bars_array >= t0) & (price_bars_array <= t1) indices = np.where(mask)[0] # Stores only non-zero indices active_indices[sample_id] = indices
Преимущества с точки зрения эффективности использования памяти
Коэффициент сжатия улучшается с увеличением размера выборки, потому что:
- Разреженность увеличивается — каждая выборка влияет лишь на небольшую долю от общего количества баров
- Фиксированные накладные расходы — структура словаря (dict) имеет минимальные базовые затраты памяти
- Эффективное хранение — используются целочисленные массивы вместо полных матриц
Влияние на производительность при последовательном бутстрепе
Сравнение алгоритмической сложности
# Standard implementation: O(n³) time, O(n²) memory def seq_bootstrap_standard(ind_mat): # Each iteration: O(n²) operations × n iterations for i in range(n_samples): avg_unique = _bootstrap_loop_run(ind_mat, prev_concurrency) # O(n²) # Optimized implementation: O(n×k) time, O(n) memory def seq_bootstrap_optimized(active_indices): # Each iteration: O(k) operations × n iterations (where k = avg event length) for i in range(n_samples): prob = _seq_bootstrap_loop(flat_indices, offsets, lengths, concurrency, uniforms) # O(k)
Основываясь на паттернах использования памяти, можно экстраполировать временную производительность:
| Операция | n=1,000 | n=8,000 | Коэффициент масштабирования |
|---|---|---|---|
| Выделение памяти | 23.75 MБ → 0.04 MБ | 1,238 MБ → 0.28 MБ | в 4 421 раз лучше |
| Матричные операции | O(1M) элементов | O(64M) элементов | в 64 раза медленнее (стандартная реализация) |
| Эффективность кэша | Низкая (большие матрицы) | Высокая (компактные массивы) | Существенное преимущество |
Создание ансамбля: SequentiallyBootstrappedBaggingClassifier
Теперь, когда у нас есть оптимизированный сэмплер для последовательного бутстрепа, мы можем интегрировать его в полноценный ансамбль машинного обучения. Класс SequentiallyBootstrappedBaggingClassifier объединяет учёт временных зависимостей, характерный для последовательного бутстрепа, со способностью ансамблевых методов снижать дисперсию.
Почему бэггинг работает — и почему ему необходим последовательный бутстреп
Бэггинг (Bootstrap Aggregating) — один из наиболее эффективных ансамблевых методов в машинном обучении. Основная идея элегантна:
- Сгенерировать множество бутстреп-выборок из обучающих данных
- Обучить отдельную модель на каждой выборке
- Агрегировать предсказания с помощью голосования (для классификации) или усреднения (для регрессии)
Этот подход прекрасно работает, когда выборки независимы. Каждая бутстреп-выборка предоставляет свой уникальный "взгляд" на данные, а агрегирование этих взглядов снижает дисперсию без увеличения смещения.
Однако в финансовом машинном обучении с перекрывающимися метками стандартный бэггинг приводит к катастрофическим результатам.
Каждая бутстреп-выборка непреднамеренно включает множество копий одних и тех же временных паттернов из-за одновременности меток (label concurrency). Ансамбль изучает одни и те же паттерны многократно, что приводит к:
- Излишне самоуверенным предсказаниям — модели видят один и тот же паттерн 10+ раз и считают его высоконадёжным
- Заниженной дисперсии — разные бутстреп-выборки не являются по-настоящему независимыми
- Плохому обобщению — истинная частота паттернов гораздо ниже, чем предполагают обучающие данные
Последовательный бутстреп решает эту проблему, гарантируя, что каждая бутстреп-выборка максимизирует временную независимость, предоставляя ансамблю по-настоящему разнообразные обучающие наборы.
Обзор архитектуры
SequentiallyBootstrappedBaggingClassifier расширяет BaggingClassifier из библиотеки scikit-learn, добавляя три ключевых изменения:
- Последовательный отбор выборок — используется seq_bootstrap_optimized вместо равномерной случайной выборки
- Отслеживание временных метаданных — хранятся samples_info_sets (время начала и окончания меток) и price_bars_index
- Предварительное вычисление активных индексов — разреженное отображение индексов строится один раз и повторно используется для всех базовых моделей (estimators)
Описание реализации
Шаг 1: Инициализация и метаданные
Классификатор требует временные метаданные, которые не нужны стандартному бэггингу:def __init__( self, samples_info_sets, # NEW: label temporal spans price_bars_index, # NEW: price bar timestamps estimator=None, n_estimators=10, max_samples=1.0, max_features=1.0, bootstrap_features=False, oob_score=False, warm_start=False, n_jobs=None, random_state=None, verbose=0, ):
Пояснение ключевых параметров:
- samples_info_sets (pd.Series): Индекс содержит время начала метки (t0), значения содержат время окончания метки (t1). Это отражает временной промежуток, к которому относится метка каждого наблюдения.
- price_bars_index (pd.DatetimeIndex) : Временные метки всех ценовых баров, использованных для создания меток. Необходим для сопоставления временных промежутков с индексами баров.
- estimator: Базовый классификатор (по умолчанию DecisionTreeClassifier). Каждый член ансамбля является клоном этого оценщика.
- n_estimators : Количество моделей в ансамбле. Большее количество оценщиков дает более сглаженные предсказания, но увеличивает время обучения.
- max_samples: Размер бутстреп-выборки. Если указано число с плавающей точкой в интервале (0, 1], это доля от размера обучающего набора; если int, это точное количество наблюдений.
- bootstrap_features: Следует ли также выполнять подвыборку признаков (увеличивает разнообразие, но может ослабить отдельные модели).
Шаг 2: Вычисление активных индексов
Перед началом семплирования мы один раз предварительно вычисляем разреженное отображение индексов:
def _fit(self, X, y, max_samples=None, sample_weight=None): # ... validation and setup ... # Compute active indices mapping (once, cached for all estimators) if self.active_indices_ is None: self.active_indices_ = get_active_indices( self.samples_info_sets, self.price_bars_index )
Почему предварительное вычисление? Вычисление активных индексов имеет сложность O(n) и является детерминированным — оно зависит только от временных меток меток, а не от случайности. Вычисление один раз и повторное использование экономит время при обучении большого количества базовых моделей.
Эффективность памяти: Как показано в разделе анализа памяти, active_indices_ использует O(n-k) памяти, где k — средняя длина метки, по сравнению с O(n²) для плотной индикаторной матрицы. Для 8 000 выборок это означает 0,28 МБ против 1 238 МБ — коэффициент сжатия 4 421:1.
Шаг 3: Генерация пользовательской бутстреп-выборки
Ключевое нововведение: замена равномерной случайной выборки на последовательный бутстреп:
def _generate_bagging_indices( random_state, bootstrap_features, n_features, max_features, max_samples, active_indices ): """Randomly draw feature and sample indices.""" # Get valid random state - this returns a RandomState object random_state_obj = check_random_state(random_state) # Draw samples using sequential bootstrap if isinstance(max_samples, numbers.Integral): sample_indices = seq_bootstrap( active_indices, sample_length=max_samples, random_seed=random_state_obj ) elif isinstance(max_samples, numbers.Real): n_samples = int(round(max_samples * len(active_indices))) sample_indices = seq_bootstrap( active_indices, sample_length=n_samples, random_seed=random_state_obj ) else: sample_indices = seq_bootstrap( active_indices, sample_length=None, random_seed=random_state_obj ) # Draw feature indices only if bootstrap_features is True if bootstrap_features: if isinstance(max_features, numbers.Integral): n_feat = max_features elif isinstance(max_features, numbers.Real): n_feat = int(round(max_features * n_features)) else: raise ValueError("max_features must be int or float when bootstrap_features=True") feature_indices = _generate_random_features( random_state_obj, bootstrap_features, n_features, n_feat ) else: # When not bootstrapping features, return None (will be handled downstream) feature_indices = None return sample_indices, feature_indices
Важное замечание: мы используем последовательный бутстреп для выборок (временное измерение), но стандартную случайную выборку для признаков (если bootstrap_features=True). Это правильно, потому что:
- Временное перекрытие возникает между наблюдениями (строками), а не признаками (столбцами)
- Корреляция признаков ортогональна временной одновременности
- Стандартный бэггинг признаков увеличивает разнообразие без возникновения временных проблем
Шаг 4: Параллельное обучение базовых моделей
Обучение нескольких базовых моделей является тривиально распараллеливаемой задачей — каждая модель может обучаться независимо:
def _parallel_build_estimators( n_estimators, ensemble, X, y, active_indices, sample_weight, seeds, total_n_estimators, verbose ): """Private function used to build a batch of estimators within a job.""" # Retrieve settings n_samples, n_features = X.shape max_samples = ensemble._max_samples max_features = ensemble.max_features bootstrap_features = ensemble.bootstrap_features support_sample_weight = has_fit_parameter(ensemble.estimator_, "sample_weight") # Build estimators estimators = [] estimators_samples = [] estimators_features = [] for i in range(n_estimators): if verbose > 1: print( "Building estimator %d of %d for this parallel run (total %d)..." % (i + 1, n_estimators, total_n_estimators) ) random_state = seeds[i] estimator = ensemble._make_estimator(append=False, random_state=random_state) # Draw samples and features sample_indices, feature_indices = _generate_bagging_indices( random_state, bootstrap_features, n_features, max_features, max_samples, active_indices ) # Draw samples, using sample weights if supported if support_sample_weight and sample_weight is not None: curr_sample_weight = sample_weight[sample_indices] else: curr_sample_weight = None # Store None for features if no bootstrapping (memory optimization) if bootstrap_features: estimators_features.append(feature_indices) else: estimators_features.append(None) # Don't store redundant feature arrays estimators_samples.append(sample_indices) # Select data if bootstrap_features: X_ = X[sample_indices][:, feature_indices] else: X_ = X[sample_indices] # Use all features y_ = y[sample_indices] estimator.fit(X_, y_, sample_weight=curr_sample_weight) estimators.append(estimator) return estimators, estimators_features, estimators_samples
Эффективность параллелизации: при n_jobs = -1 реализация использует все ядра процессора. Обучение 100 базовых моделей на 8 ядрах означает, что на каждом ядре обрабатывается около 12 моделей одновременно. Это обеспечивает почти линейное ускорение для больших ансамблей.
Шаг 5: Оценка по вневыборочным наблюдениям (Out-of-Bag, OOB)
Одна из самых ценных функций бэггинга — встроенная валидация с использованием OOB-выборок:
def _set_oob_score(self, X, y): """Compute out-of-bag score""" # Safeguard: Ensure n_classes_ is set if not hasattr(self, "n_classes_"): self.classes_ = np.unique(y) self.n_classes_ = len(self.classes_) n_samples = y.shape[0] n_classes = self.n_classes_ predictions = np.zeros((n_samples, n_classes)) for estimator, samples, features in zip( self.estimators_, self._estimators_samples, self.estimators_features_ ): # Create mask for OOB samples mask = ~indices_to_mask(samples, n_samples) if np.any(mask): # Get predictions for OOB samples X_oob = X[mask] # If features is None, use all features; otherwise subset if features is not None: X_oob = X_oob[:, features] predictions[mask] += estimator.predict_proba(X_oob) # Average predictions denominator = np.sum(predictions != 0, axis=1) denominator[denominator == 0] = 1 # avoid division by zero predictions /= denominator[:, np.newaxis] # Compute OOB score oob_decision_function = predictions oob_prediction = np.argmax(predictions, axis=1) if n_classes == 2: oob_prediction = oob_prediction.astype(np.int64) self.oob_decision_function_ = oob_decision_function self.oob_prediction_ = oob_prediction self.oob_score_ = accuracy_score(y, oob_prediction)
Почему OOB важна в финансовом машинном обучении:
- Отсутствие потери данных: Каждое наблюдение выполняет двойную функцию — обучает одни базовые модели и валидирует другие
- Объективные оценки: OOB-оценка приближает результаты кросс-валидации без вычислительных затрат
- Сигнал для ранней остановки: Мониторинг OOB-оценки в процессе обучения позволяет обнаружить переобучение
- Временная безопасность: При использовании последовательного бутстрепа OOB-выборки являются по-настоящему независимыми с временной точки зрения
Шаг 6: Расширение классов
Мы объединяем всё вместе, создавая классы SequentiallyBootstrappedBaseBagging, SequentiallyBootstrappedBaggingClassifier и SequentiallyBootstrappedBaggingRegressor, которые можно найти в файле sb_bagging.py.
Полный пример использования
import pandas as pd import numpy as np from sklearn.tree import DecisionTreeClassifier from sklearn.metrics import classification_report # Assume we have triple-barrier labels from Part 3 # samples_info_sets: pd.Series with index=t0, values=t1 # price_bars: pd.DataFrame with DatetimeIndex # X: feature matrix, y: labels # Initialize classifier clf = SequentiallyBootstrappedBaggingClassifier( samples_info_sets=samples_info_sets, # Label temporal spans price_bars_index=price_bars.index, # Bar timestamps estimator=DecisionTreeClassifier( max_depth=6, min_samples_leaf=50 ), n_estimators=100, # Large ensemble for stability max_samples=0.5, # Use 50% of data per estimator bootstrap_features=True, # Also subsample features max_features=0.7, # Use 70% of features per estimator oob_score=True, # Enable OOB validation n_jobs=-1, # Use all CPU cores random_state=42, # Reproducibility verbose=1 ) # Train ensemble clf.fit(X_train, y_train) # Inspect OOB performance (no test set needed!) print(f"OOB Score: {clf.oob_score_:.4f}") # Make predictions on test set y_pred = clf.predict(X_test) y_proba = clf.predict_proba(X_test) # Evaluate print(classification_report(y_test, y_pred)) # Access individual estimators if needed print(f"Number of estimators: {len(clf.estimators_)}") print(f"Average sample size: {np.mean([len(s) for s in clf.estimators_samples_]):.0f}")
Рекомендации по настройке параметров
n_estimators(количество моделей)
- Небольшие наборы данных (<1 000 выборок): 50–100 моделей
- Средние наборы данных (1 000–10 000): 100–200 моделей
- Крупные наборы данных (>10 000): 200–500 моделей
- Эмпирическое правило: больше — лучше, пока OOB-оценка не выйдет на плато (отслеживайте в процессе обучения)
max_samples(Размер бутстреп-выборки)
- Высокая одновременность (>10 перекрытий на бар): Используйте меньший размер выборки (0.3–0.5), чтобы максимизировать разнообразие
- Низкая одновременность (<5 перекрытий на бар): Можно безопасно использовать больший размер выборки (0.6–0.8)
- Компромисс: Меньший размер выборки → больше разнообразия, но слабее отдельные модели
- Включать, когда: большое количество признаков (>50), признаки коррелированы, требуется максимальное разнообразие.
- Отключать, когда: мало признаков (<20), каждый признак критичен, важна интерпретируемость
- Рекомендуемое значение max_features: 0.5–0.7 при включенной подвыборке (слишком низкое значение ослабляет отдельные модели)
Сравнение: стандартный бэггинг vs. бэггинг с последовательным бутстрепом
Давайте посмотрим на разницу в производительности на примере реальной торговой стратегии:
from sklearn.ensemble import BaggingClassifier # Standard bagging (temporal leakage) standard_clf = BaggingClassifier( estimator=DecisionTreeClassifier(max_depth=6), n_estimators=100, max_samples=0.5, oob_score=True, random_state=42 ) # Sequential bootstrap bagging (temporal awareness) sequential_clf = SequentiallyBootstrappedBaggingClassifier( samples_info_sets=samples_info_sets, price_bars_index=price_bars.index, estimator=DecisionTreeClassifier(max_depth=6), n_estimators=100, max_samples=0.5, oob_score=True, random_state=42 ) # Train both standard_clf.fit(X_train, y_train) sequential_clf.fit(X_train, y_train) # Compare results print("Standard Bagging:") print(f" OOB Score: {standard_clf.oob_score_:.4f}") print(f" Test Accuracy: {standard_clf.score(X_test, y_test):.4f}") print("\nSequential Bootstrap Bagging:") print(f" OOB Score: {sequential_clf.oob_score_:.4f}") print(f" Test Accuracy: {sequential_clf.score(X_test, y_test):.4f}")
Типичные результаты на финансовых данных с высокой степенью одновременности:
| Метрика | Стандартный бэггинг | Бэггинг с последовательным бутстрепом | Улучшение |
|---|---|---|---|
| Разрыв между OOB и тестом | 0.124 | 0.013 | -89.5% |
Интеграция с кросс-валидацией
Хотя OOB-оценка удобна, для надлежащей оценки требуется «очищенная» кросс-валидация (purged cross-validation), чтобы предотвратить временную утечку данных:
from mlfinlab.cross_validation import PurgedKFold # Setup purged cross-validation cv = PurgedKFold( n_splits=5, samples_info_sets=samples_info_sets, pct_embargo=0.01 # Embargo 1% of data after each fold )
Важно: Даже при использовании последовательного бутстрепа для семплирования внутри каждой базовой модели, вам всё равно необходима "очищенная" кросс-валидация (purged CV) для корректной оценки ансамбля в целом. Последовательный бутстреп устраняет перекрытия внутри каждой модели; очищенная кросс-валидация предотвращает временную утечку между фолдами.
Мы должны реализовать собственные методы кросс-валидации, чтобы адаптировать их для наших моделей бэггинга с последовательным бутстрепом. Функция, представленная ниже, даёт детальную картину происходящего в каждом фолде, и необходима для более глубокого изучения временных зависимостей ваших данных.
def analyze_cross_val_scores( 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: tuple(dict, pd.DataFrame, dict) The computed scores, a data frame of mean and std. deviation, and a dict of data in each fold """ scoring_methods = [ accuracy_score, probability_weighted_accuracy, log_loss, precision_score, recall_score, f1_score, ] ret_scores = { ( scoring.__name__.replace("_score", "") .replace("probability_weighted_accuracy", "pwa") .replace("log_loss", "neg_log_loss") ): np.zeros(cv_gen.n_splits) 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],)) seq_bootstrap = isinstance(classifier, SequentiallyBootstrappedBaggingClassifier) if seq_bootstrap: t1 = classifier.samples_info_sets.copy() common_idx = t1.index.intersection(y.index) X, y, t1 = X.loc[common_idx], y.loc[common_idx], t1.loc[common_idx] if t1.empty: raise KeyError(f"samples_info_sets not aligned with data") classifier.set_params(oob_score=False) cms = [] # To store confusion matrices # Score model on KFolds for i, (train, test) in enumerate(cv_gen.split(X=X, y=y)): if seq_bootstrap: classifier = clone(classifier).set_params( samples_info_sets=t1.iloc[train] ) # Create new instance 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, :]) params = dict( y_true=y.iloc[test], y_pred=pred, labels=classifier.classes_, sample_weight=sample_weight_score[test], ) for method, scoring in zip(ret_scores.keys(), scoring_methods): if scoring in (probability_weighted_accuracy, log_loss): score = scoring( y.iloc[test], prob, sample_weight=sample_weight_score[test], labels=classifier.classes_, ) if method == "neg_log_loss": score *= -1 else: try: score = scoring(**params) except: del params["labels"] score = scoring(**params) params["labels"] = classifier.classes_ ret_scores[method][i] = score cms.append(confusion_matrix(**params).round(2)) # Mean and standard deviation of scores scores_df = pd.DataFrame.from_dict( { scoring: {"mean": scores.mean(), "std": scores.std()} for scoring, scores in ret_scores.items() }, orient="index", ) # Extract TN, TP, FP, FN for each fold confusion_matrix_breakdown = [] for i, cm in enumerate(cms, 1): if cm.shape == (2, 2): # Binary classification tn, fp, fn, tp = cm.ravel() confusion_matrix_breakdown.append({"fold": i, "TN": tn, "FP": fp, "FN": fn, "TP": tp}) else: # For multi-class, you might want different handling confusion_matrix_breakdown.append({"fold": i, "confusion_matrix": cm}) return ret_scores, scores_df, confusion_matrix_breakdown
Ниже представлены результаты стратегии, основанной на мета-маркированных (meta-labeled) полосах Боллинджера и возврате к среднему (см. sample_weights.ipynb). Все показатели лучше и имеют меньшую дисперсию, когда используется последовательный бутстреп.
Результаты кросс-валидации:
| Случайный лес | Стандартный бэггинг | Бэггинг с последовательным бутстрепом | |
|---|---|---|---|
| accuracy | 0.509 ± 0.024 | 0.515 ± 0.024 | 0.527 ± 0.015 |
| pwa | 0.513 ± 0.038 | 0.519 ± 0.039 | 0.544 ± 0.018 |
| neg_log_loss | -0.695 ± 0.005 | -0.694 ± 0.005 | -0.692 ± 0.001 |
| precision | 0.637 ± 0.027 | 0.643 ± 0.026 | 0.637 ± 0.026 |
| recall | 0.476 ± 0.095 | 0.484 ± 0.098 | 0.567 ± 0.038 |
| f1 | 0.539 ± 0.065 | 0.546 ± 0.067 | 0.599 ± 0.026 |
Результаты на вневыборочных данных:
| Случайный лес | Стандартный бэггинг | Бэггинг с последовательным бутстрепом | |
|---|---|---|---|
| accuracy | 0.505780 | 0.496628 | 0.519750 |
| pwa | 0.493505 | 0.495487 | 0.523738 |
| neg_log_loss | -0.696703 | -0.696612 | -0.692669 |
| precision | 0.650811 | 0.646396 | 0.633913 |
| recall | 0.461303 | 0.439847 | 0.558621 |
| f1 | 0.539910 | 0.523484 | 0.593890 |
| oob | 0.516976 | 0.516133 | 0.522153 |
| oob_test_gap | 0.011195 | 0.019505 | 0.002403 |
Ключевые наблюдения:
- Меньший разрыв между OOB и тестом для последовательного бэггинга (0.002 против 0.019) показывает, что OOB-оценка заслуживает доверия — производительность на OOB и тестовых данных тесно совпадает, что указывает на отсутствие скрытой временной утечки данных.
- Более высокая точность на тесте демонстрирует лучшую способность к обобщению на действительно новых данных
Продвинутый уровень: пользовательские OOB-метрики
Встроенная оценка oob_score_ использует точность (accuracy) для классификации и коэффициент детерминации (R²) для регрессии. Для финансовых приложений часто требуются пользовательские метрики:
def compute_custom_oob_metrics(clf, X, y, sample_weight=None): """ Compute custom OOB metrics (F1, AUC, precision/recall) for a fitted ensemble. Args: clf: Fitted SequentiallyBootstrappedBaggingClassifier X: Feature matrix used in training y: True labels sample_weight: Optional sample weights Returns: dict: Custom OOB metric values """ from sklearn.metrics import f1_score, roc_auc_score, precision_score, recall_score n_samples = y.shape[0] n_classes = clf.n_classes_ # Accumulate OOB predictions oob_proba = np.zeros((n_samples, n_classes)) oob_count = np.zeros(n_samples) for estimator, samples, features in zip( clf.estimators_, clf.estimators_samples_, clf.estimators_features_ ): mask = ~indices_to_mask(samples, n_samples) if np.any(mask): X_oob = X[mask][:, features] oob_proba[mask] += estimator.predict_proba(X_oob) oob_count[mask] += 1 # Average and get predictions oob_mask = oob_count > 0 oob_proba[oob_mask] /= oob_count[oob_mask, np.newaxis] oob_pred = np.argmax(oob_proba, axis=1) # Compute metrics on samples with OOB predictions y_oob = y[oob_mask] pred_oob = oob_pred[oob_mask] proba_oob = oob_proba[oob_mask] metrics = { 'f1': f1_score(y_oob, pred_oob, average='weighted'), 'precision': precision_score(y_oob, pred_oob, average='weighted'), 'recall': recall_score(y_oob, pred_oob, average='weighted'), 'coverage': oob_mask.sum() / n_samples # Fraction with OOB predictions } # Add AUC for binary classification if n_classes == 2: metrics['auc'] = roc_auc_score(y_oob, proba_oob[:, 1]) return metrics # Usage oob_metrics = compute_custom_oob_metrics(sequential_clf, X_train, y_train) print("Custom OOB Metrics:") for metric, value in oob_metrics.items(): print(f" {metric}: {value:.4f}")
Рекомендации по развертыванию в производственной среде
Управление памятью
Крупные ансамбли моделей могут потреблять значительный объем памяти. Следите за использованием памяти и оптимизируйте ее:
import sys # Check ensemble memory footprint def estimate_ensemble_size(clf): """Estimate memory usage of fitted ensemble.""" total_bytes = 0 # Estimators for est in clf.estimators_: total_bytes += sys.getsizeof(est) # Sample indices for samples in clf.estimators_samples_: total_bytes += samples.nbytes # Feature indices if clf.estimators_features_ is not None: for features in clf.estimators_features_: total_bytes += features.nbytes return total_bytes / (1024 ** 2) # Convert to MB size_mb = estimate_ensemble_size(sequential_clf) print(f"Ensemble size: {size_mb:.2f} MB")
Сериализация моделей
Эффективно сохраняйте и загружайте обученные ансамбли:
import joblib # Save entire ensemble joblib.dump(sequential_clf, 'sequential_bagging_model.pkl', compress=3) # Load for prediction loaded_clf = joblib.load('sequential_bagging_model.pkl') # Verify predictions match original_pred = sequential_clf.predict_proba(X_test) loaded_pred = loaded_clf.predict_proba(X_test) assert np.allclose(original_pred, loaded_pred)
Типичные ошибки и способы их решения
Ошибка 1: Отсутствие передачи временных метаданных
Проблема: Попытка использовать классификатор без samples_info_sets или price_bars_index.Решение: Всегда убеждайтесь, что эти параметры правильно сформированы в процессе подготовки меток:
# From triple-barrier labeling (Part 3) events = get_events( close=close_prices, t_events=trigger_times, pt_sl=[1, 1], target=daily_vol, min_ret=0.01, num_threads=4, vertical_barrier_times=vertical_barriers ) # events['t1'] contains end times - this is samples_info_sets samples_info_sets = events['t1'] price_bars_index = close_prices.index # Now safe to use clf = SequentiallyBootstrappedBaggingClassifier( samples_info_sets=samples_info_sets, price_bars_index=price_bars_index, # ... other params ... )
Ошибка 2: Несоответствие длины индексов
Проблема: Неравенство len(samples_info_sets) != len(X) приводит к неочевидным ошибкам.Решение: Всегда согласовывайте признаки, метки и метаданные:
# After computing features and labels, ensure alignment assert len(X) == len(y) == len(samples_info_sets), \ "Feature matrix, labels, and metadata must have same length" # If they don't match, use index intersection common_idx = X.index.intersection(y.index).intersection(samples_info_sets.index) X_aligned = X.loc[common_idx] y_aligned = y.loc[common_idx] samples_aligned = samples_info_sets.loc[common_idx]
Ошибка 3: Игнорирование поведения "горячего старта" (warm start)
Проблема: Установка warm_start=True с последующим изменением n_estimators не приводит к переобучению существующих оценщиков.Решение: Помните, что "горячий старт" только добавляет новые оценщики:
# Initial training with 50 estimators clf = SequentiallyBootstrappedBaggingClassifier( samples_info_sets=samples_info_sets, price_bars_index=price_bars_index, n_estimators=50, warm_start=True, random_state=42 ) clf.fit(X_train, y_train) # Add 50 more estimators (total=100) clf.n_estimators = 100 clf.fit(X_train, y_train) # Only trains 50 new estimators print(len(clf.estimators_)) # Output: 100
Сравнение с альтернативными методами
Как последовательный бутстреп-бэггинг (sequential bootstrap bagging) соотносится с другими методами ансамблей на финансовых данных?
from sklearn.ensemble import ( RandomForestClassifier, GradientBoostingClassifier, BaggingClassifier ) from sklearn.model_selection import cross_val_score # Define models models = { 'Standard Bagging': BaggingClassifier( estimator=DecisionTreeClassifier(max_depth=6), n_estimators=100, random_state=42 ), 'Random Forest': RandomForestClassifier( n_estimators=100, max_depth=6, random_state=42 ), 'Sequential Bagging': SequentiallyBootstrappedBaggingClassifier( samples_info_sets=samples_info_sets, price_bars_index=price_bars_index, estimator=DecisionTreeClassifier(max_depth=6), n_estimators=100, random_state=42 ) } # Benchmark with purged K-Fold CV results = {} cv_gen = PurgedKFold(n_splits, t1, pct_embargo) for name, model in models.items(): raw_scores, scores_df, folds = analyze_cross_val_scores( model, X, y, cv_gen, sample_weights_train=w, sample_weights_score=w, ) results[name] = dict(scores=scores_df, folds=folds)
Итоги и рекомендации по использованию
Классификатор SequentiallyBootstrappedBaggingClassifier объединяет возможности ансамблевого обучения с учетом особенностей финансовых временных рядов, решая фундаментальную проблему пересечения меток (label concurrency). Ниже приведены ключевые выводы:
Когда использовать последовательный бутстреп-бэггинг:
- Тройная барьерная разметка или любой другой метод, создающий временные пересечения меток
- Высокочастотные данные, где наблюдения естественным образом пересекаются
- Любая задача финансового машинного обучения, где важна временная структура
- Продукционные системы, требующие корректных оценок дисперсии
Когда стандартного бэггинга достаточно:
- Данные дневной или более низкой частоты с минимальным пересечением меток
- Кросс-секционные прогнозы (прогнозирование по активам, а не во времени)
- Сценарии, где временное засорение (temporal leakage) устранено другими способами
Контрольный список для настройки в производстве:
- ✓ Убедиться, что samples_info_sets и price_bars_index правильно согласованы
- ✓ Включить oob_score=True для мониторинга в процессе обучения
- ✓ Установить n_jobs=-1 для использования всех ядер CPU
- ✓ Использовать random_state для воспроизводимости результатов
- ✓ Контролировать использование памяти для больших ансамблей
- ✓ Проводить валидацию с помощью кросс-валидации с очисткой/эмбарго (purged/embargoed cross-validation)
- ✓ Сравнивать OOB-оценку с производительностью на тестовой выборке для выявления остаточных утечек
Советы по оптимизации производительности:
- Предварительно вычислить active_indices_ один раз и закэшировать результат
- Использовать меньшее значение max_samples при высокой степени пересечения
- Включать bootstrap_features для данных высокой размерности
- Применять пакетное прогнозирование для приложений с низкой задержкой
- Сериализовать модели со сжатием для развертывания
Благодаря последовательному бутстреп-бэггингу вы получаете готовый к использованию в продакшене ансамблевый метод, который учитывает временную структуру финансовых данных и при этом обеспечивает снижение дисперсии — ключевое преимущество, делающее бэггинг столь эффективным в классическом машинном обучении.
Развертывание последовательных бутстреп-моделей в MQL5 через ONNX
После обучения надежных последовательных бутстреп-моделей в Python следующим важным шагом является их развертывание в MetaTrader 5 для реальной торговли. ONNX (Open Neural Network Exchange) обеспечивает наиболее надежный мост между экосистемой машинного обучения Python и производственной средой MQL5.
Почему ONNX для развертывания в MQL5
ONNX предлагает несколько убедительных преимуществ для развертывания моделей финансового машинного обучения:
- Нативная поддержка MetaTrader 5 – MetaTrader 5 имеет встроенную среду выполнения ONNX, не требует внешних зависимостей
- Производительность – Модели выполняются как скомпилированный код C++, обеспечивая прогнозы на микросекундном уровне
- Кроссплатформенность – Одна и та же модель работает на установках MetaTrader 5 в Windows, Mac и Linux
- Широкая совместимость – Поддерживает ансамбли scikit-learn, включая наши последовательные бутстреп-модели
- Управление версиями – Двоичные файлы моделей легко версионировать и развертывать
Ключевые ограничения, которые необходимо понимать:
- Метаданные ансамбля (OOB-оценки, выборки оценщиков) не сохраняются — только логика прогнозирования
- Модели нельзя дообучать в MQL5; Python остается средой для обучения
- Крупные ансамбли (200+ оценщиков) увеличивают время загрузки модели и потребление памяти
- Вычисление признаков должно быть вручную реализовано в MQL5 с полным соответствием Python-версии
Полный конвейер развертывания
Шаг 1: Экспорт обученной модели в формат ONNX
После обучения последовательного бутстреп-классификатора преобразуйте его в ONNX:
import onnx from skl2onnx import convert_sklearn from skl2onnx.common.data_types import FloatTensorType import numpy as np # Your trained sequential bootstrap model clf = SequentiallyBootstrappedBaggingClassifier( samples_info_sets=samples_info_sets, price_bars_index=price_bars.index, estimator=DecisionTreeClassifier(max_depth=6, min_samples_leaf=50), n_estimators=100, max_samples=0.5, random_state=42 ) clf.fit(X_train, y_train) # Define input shape - CRITICAL: must match feature count exactly n_features = X_train.shape[1] initial_type = [('float_input', FloatTensorType([None, n_features]))] # Convert to ONNX with appropriate settings onnx_model = convert_sklearn( clf, initial_types=initial_type, target_opset=12, # MT5 supports opset 9-15 options={ 'zipmap': False # Return raw probabilities, not dictionary } ) # Save model file model_filename = "sequential_bagging_model.onnx" with open(model_filename, "wb") as f: f.write(onnx_model.SerializeToString()) print(f"Model exported: {len(onnx_model.SerializeToString()) / 1024:.2f} KB") print(f"Input features: {n_features}") print(f"Output classes: {len(clf.classes_)}")
Шаг 2: Проверка корректности ONNX-модели
Перед развертыванием всегда проверяйте, что прогнозы ONNX-модели совпадают с прогнозами исходной модели:
import onnxruntime as rt # Load ONNX model sess = rt.InferenceSession(model_filename) # Inspect model structure input_name = sess.get_inputs()[0].name output_name = sess.get_outputs()[0].name print(f"Input tensor name: {input_name}") print(f"Output tensor name: {output_name}") # Test with sample data X_test_sample = X_test[:5].astype(np.float32) # Original model predictions sklearn_pred = clf.predict_proba(X_test_sample) # ONNX model predictions onnx_pred = sess.run([output_name], {input_name: X_test_sample})[0] # Verify predictions match within tolerance print("\nVerification Results:") print("Scikit-learn predictions:\n", sklearn_pred[:3]) print("\nONNX predictions:\n", onnx_pred[:3]) print(f"\nMax absolute difference: {np.abs(sklearn_pred - onnx_pred).max():.2e}") assert np.allclose(sklearn_pred, onnx_pred, atol=1e-5), "ERROR: Predictions don't match!" print("✓ Model verification passed")
Шаг 3: Документирование конвейера построения признаков
Наиболее частой причиной сбоев при развертывании является несоответствие признаков. Задокументируйте точные вычисления ваших признаков:
import json from datetime import datetime # Document feature metadata for MQL5 implementation feature_metadata = { 'model_version': 'v1.0_seq_bagging', 'timestamp': datetime.now().isoformat(), 'n_features': n_features, 'n_estimators': 100, 'lookback_period': 20, 'feature_names': [ 'bb_position', # (close - bb_middle) / (bb_upper - bb_lower) 'bb_width', # (bb_upper - bb_lower) / bb_middle 'return_1d', # (close[0] - close[1]) / close[1] 'return_5d', # (close[0] - close[5]) / close[5] 'volatility_20d', # std(returns, 20) / close[0] 'volume_ratio', # volume[0] / ma(volume, 20) 'rsi_14', # RSI with 14-period lookback 'mean_reversion_z', # (close - ma_20) / std_20 ], 'bb_parameters': { 'period': 20, 'std_dev': 2.0 } } with open('feature_metadata.json', 'w') as f: json.dump(feature_metadata, f, indent=2) # Create test dataset for validation in MQL5 test_data = { 'features': X_test[:10].tolist(), 'expected_predictions': clf.predict_proba(X_test[:10]).tolist(), 'expected_classes': clf.predict(X_test[:10]).tolist() } with open('test_predictions.json', 'w') as f: json.dump(test_data, f, indent=2)
Реализация в MQL5: стратегия возврата к среднему на основе полос Боллинджера
Теперь мы реализуем полную систему в MQL5, начиная с точного инжиниринга признаков, который соответствует нашему обучению в Python.
Модуль расчета признаков
Создайте FeatureEngine.mqh для точного воспроизведения расчетов признаков, выполненных в Python:
//+------------------------------------------------------------------+ //| FeatureEngine.mqh | //| Feature calculation engine matching Python training pipeline | //+------------------------------------------------------------------+ #property strict class CFeatureEngine { private: int m_lookback; int m_bb_period; double m_bb_deviation; int m_rsi_period; public: CFeatureEngine(int lookback=20, int bb_period=20, double bb_dev=2.0, int rsi_period=14) { m_lookback = lookback; m_bb_period = bb_period; m_bb_deviation = bb_dev; m_rsi_period = rsi_period; } // Main feature calculation - must match Python exactly bool CalculateFeatures(const double &close[], const double &high[], const double &low[], const long &volume[], double &features[]) { if(ArraySize(close) < m_lookback + 10) return false; // Must have exactly 8 features to match Python ArrayResize(features, 8); int idx = 0; // Calculate Bollinger Bands double bb_upper, bb_middle, bb_lower; CalculateBollingerBands(close, m_bb_period, m_bb_deviation, bb_upper, bb_middle, bb_lower); // Feature 1: Bollinger Band Position // Measures where price sits within the bands (-1 to +1) double bb_range = bb_upper - bb_lower; if(bb_range > 0) { features[idx++] = (close[0] - bb_middle) / bb_range; } else { features[idx++] = 0.0; } // Feature 2: Bollinger Band Width // Normalized measure of volatility if(bb_middle > 0) { features[idx++] = bb_range / bb_middle; } else { features[idx++] = 0.0; } // Feature 3: 1-day return features[idx++] = SafeReturn(close[0], close[1]); // Feature 4: 5-day return features[idx++] = SafeReturn(close[0], close[5]); // Feature 5: 20-day volatility (annualized) double returns_std = CalculateReturnsStdDev(close, m_lookback); features[idx++] = returns_std / close[0]; // Feature 6: Volume ratio double vol_ma = CalculateVolumeMA(volume, m_lookback); if(vol_ma > 0) { features[idx++] = (double)volume[0] / vol_ma; } else { features[idx++] = 1.0; } // Feature 7: RSI features[idx++] = CalculateRSI(close, m_rsi_period) / 100.0; // Feature 8: Mean reversion Z-score double ma_20 = CalculateMA(close, 20); double std_20 = CalculateStdDev(close, 20); if(std_20 > 0) { features[idx++] = (close[0] - ma_20) / std_20; } else { features[idx++] = 0.0; } return true; } private: // Calculate Bollinger Bands using SMA and standard deviation void CalculateBollingerBands(const double &close[], int period, double deviation, double &upper, double &middle, double &lower) { middle = CalculateMA(close, period); double std = CalculateStdDev(close, period); upper = middle + deviation * std; lower = middle - deviation * std; } // Simple Moving Average double CalculateMA(const double &data[], int period) { double sum = 0.0; for(int i = 0; i < period; i++) { sum += data[i]; } return sum / period; } // Standard Deviation double CalculateStdDev(const double &data[], int period) { double mean = CalculateMA(data, period); double sum_sq = 0.0; for(int i = 0; i < period; i++) { double diff = data[i] - mean; sum_sq += diff * diff; } return MathSqrt(sum_sq / period); } // Standard deviation of returns (not prices) double CalculateReturnsStdDev(const double &close[], int period) { double returns[]; ArrayResize(returns, period); for(int i = 0; i < period; i++) { returns[i] = SafeReturn(close[i], close[i+1]); } return CalculateStdDev(returns, period); } // RSI calculation // RSI calculation double CalculateRSI(const double &close[], int period) { double gains = 0.0, losses = 0.0; for(int i = 1; i <= period; i++) { double change = close[i-1] - close[i]; if(change > 0) { gains += change; } else { losses -= change; } } double avg_gain = gains / period; double avg_loss = losses / period; if(avg_loss == 0.0) return 100.0; double rs = avg_gain / avg_loss; return 100.0 - (100.0 / (1.0 + rs)); } // Volume moving average double CalculateVolumeMA(const long &volume[], int period) { double sum = 0.0; for(int i = 0; i < period; i++) { sum += (double)volume[i]; } return sum / period; } // Safe return calculation with division by zero protection double SafeReturn(double current, double previous) { if(previous == 0.0 || MathAbs(previous) < 1e-10) return 0.0; return (current - previous) / previous; } };
Главный эксперт с интеграцией ONNX
Создайте эксперта, который загружает ONNX-модель и реализует стратегию возврата к среднему на основе полос Боллинджера:
//+------------------------------------------------------------------+ //| SequentialBaggingEA.mq5 | //| Bollinger Band Mean Reversion with Sequential Bootstrap Model | //+------------------------------------------------------------------+ #property copyright "Your Name" #property version "1.00" #property strict #include <Trade\Trade.mqh> #include "FeatureEngine.mqh" //--- Input parameters input group "Model Settings" input string InpModelFile = "sequential_bagging_model.onnx"; // ONNX model filename input double InpConfidenceThreshold = 0.60; // Minimum confidence for trade input group "Feature Parameters" input int InpLookback = 20; // Feature lookback period input int InpBBPeriod = 20; // Bollinger Bands period input double InpBBDeviation = 2.0; // Bollinger Bands deviation input int InpRSIPeriod = 14; // RSI period input group "Risk Management" input double InpRiskPercent = 1.0; // Risk per trade (%) input int InpStopLoss = 200; // Stop loss (points) input int InpTakeProfit = 400; // Take profit (points) input int InpMaxTrades = 1; // Maximum concurrent trades input group "Trading Hours" input bool InpUseTradingHours = false; // Enable trading hours filter input int InpStartHour = 9; // Trading start hour input int InpEndHour = 17; // Trading end hour //--- Global variables long g_model_handle = INVALID_HANDLE; CTrade g_trade; CFeatureEngine g_features; datetime g_last_bar_time = 0; //+------------------------------------------------------------------+ //| Expert initialization function | //+------------------------------------------------------------------+ int OnInit() { // Initialize feature engine g_features.CFeatureEngine(InpLookback, InpBBPeriod, InpBBDeviation, InpRSIPeriod); // Load ONNX model from MQL5/Files directory g_model_handle = OnnxCreateFromFile( InpModelFile, ONNX_DEFAULT ); if(g_model_handle == INVALID_HANDLE) { Print("❌ Failed to load ONNX model: ", InpModelFile); Print("Ensure model is in: Terminal_Data_Folder/MQL5/Files/"); return INIT_FAILED; } // Verify model structure long input_count, output_count; OnnxGetInputCount(g_model_handle, input_count); OnnxGetOutputCount(g_model_handle, output_count); vector input_shape; OnnxGetInputShape(g_model_handle, 0, input_shape); Print("✓ Model loaded successfully"); Print(" Model file: ", InpModelFile); Print(" Input count: ", input_count); Print(" Output count: ", output_count); Print(" Expected features: ", (int)input_shape[1]); Print(" Confidence threshold: ", InpConfidenceThreshold); // Set trade parameters g_trade.SetExpertMagicNumber(20241102); g_trade.SetDeviationInPoints(10); g_trade.SetTypeFilling(ORDER_FILLING_FOK); return INIT_SUCCEEDED; } //+------------------------------------------------------------------+ //| Expert deinitialization function | //+------------------------------------------------------------------+ void OnDeinit(const int reason) { if(g_model_handle != INVALID_HANDLE) { OnnxRelease(g_model_handle); Print("Model released"); } } //+------------------------------------------------------------------+ //| Expert tick function | //+------------------------------------------------------------------+ void OnTick() { // Check for new bar datetime current_bar_time = iTime(_Symbol, _Period, 0); if(current_bar_time == g_last_bar_time) return; g_last_bar_time = current_bar_time; // Trading hours filter if(InpUseTradingHours) { MqlDateTime dt; TimeToStruct(TimeCurrent(), dt); if(dt.hour < InpStartHour || dt.hour >= InpEndHour) return; } // Get market data double close[], high[], low[]; long volume[]; ArraySetAsSeries(close, true); ArraySetAsSeries(high, true); ArraySetAsSeries(low, true); ArraySetAsSeries(volume, true); int required_bars = InpLookback + 10; int copied = CopyClose(_Symbol, _Period, 0, required_bars, close); if(copied < required_bars) { Print("Insufficient bars: ", copied, " < ", required_bars); return; } CopyHigh(_Symbol, _Period, 0, required_bars, high); CopyLow(_Symbol, _Period, 0, required_bars, low); CopyTickVolume(_Symbol, _Period, 0, required_bars, volume); // Calculate features double feature_array[]; if(!g_features.CalculateFeatures(close, high, low, volume, feature_array)) { Print("Feature calculation failed"); return; } // Prepare input matrix for ONNX (must be float32) matrix input_matrix(1, ArraySize(feature_array)); for(int i = 0; i < ArraySize(feature_array); i++) { input_matrix[0][i] = (float)feature_array[i]; } // Run model inference matrix output_matrix; if(!OnnxRun(g_model_handle, ONNX_NO_CONVERSION, input_matrix, output_matrix)) { Print("❌ Model prediction failed!"); return; } // Extract probabilities // Output shape: [1, 2] for binary classification // Class 0 = SELL signal, Class 1 = BUY signal double prob_sell = output_matrix[0][0]; double prob_buy = output_matrix[0][1]; // Log predictions for monitoring Comment(StringFormat( "Sequential Bootstrap EA\n" + "Time: %s\n" + "Prob SELL: %.2f%%\n" + "Prob BUY: %.2f%%\n" + "Threshold: %.2f%%\n" + "Positions: %d/%d", TimeToString(TimeCurrent(), TIME_DATE|TIME_MINUTES), prob_sell * 100, prob_buy * 100, InpConfidenceThreshold * 100, PositionsTotal(), InpMaxTrades )); // Trading logic: Mean reversion strategy if(PositionsTotal() < InpMaxTrades) { // BUY signal: Price at lower band, model predicts reversion up if(prob_buy > InpConfidenceThreshold) { ExecuteBuy(prob_buy, feature_array); } // SELL signal: Price at upper band, model predicts reversion down else if(prob_sell > InpConfidenceThreshold) { ExecuteSell(prob_sell, feature_array); } } } //+------------------------------------------------------------------+ //| Execute BUY order | //+------------------------------------------------------------------+ void ExecuteBuy(double confidence, const double &features[]) { double price = SymbolInfoDouble(_Symbol, SYMBOL_ASK); double sl = price - InpStopLoss * _Point; double tp = price + InpTakeProfit * _Point; // Calculate position size based on risk double lot = CalculateLotSize(InpRiskPercent, InpStopLoss); // Build comment with BB position for analysis string comment = StringFormat( "SB|BUY|Conf:%.2f|BB:%.3f", confidence, features[0] // BB position feature ); if(g_trade.Buy(lot, _Symbol, price, sl, tp, comment)) { Print("✓ BUY executed: Lot=", lot, " Conf=", confidence, " BB=", features[0]); } else { Print("❌ BUY failed: ", g_trade.ResultRetcodeDescription()); } } //+------------------------------------------------------------------+ //| Execute SELL order | //+------------------------------------------------------------------+ void ExecuteSell(double confidence, const double &features[]) { double price = SymbolInfoDouble(_Symbol, SYMBOL_BID); double sl = price + InpStopLoss * _Point; double tp = price - InpTakeProfit * _Point; double lot = CalculateLotSize(InpRiskPercent, InpStopLoss); string comment = StringFormat( "SB|SELL|Conf:%.2f|BB:%.3f", confidence, features[0] ); if(g_trade.Sell(lot, _Symbol, price, sl, tp, comment)) { Print("✓ SELL executed: Lot=", lot, " Conf=", confidence, " BB=", features[0]); } else { Print("❌ SELL failed: ", g_trade.ResultRetcodeDescription()); } } //+------------------------------------------------------------------+ //| Calculate lot size based on risk percentage | //+------------------------------------------------------------------+ double CalculateLotSize(double risk_percent, int sl_points) { double account_balance = AccountInfoDouble(ACCOUNT_BALANCE); double risk_amount = account_balance * risk_percent / 100.0; double tick_value = SymbolInfoDouble(_Symbol, SYMBOL_TRADE_TICK_VALUE); double tick_size = SymbolInfoDouble(_Symbol, SYMBOL_TRADE_TICK_SIZE); // Calculate value of stop loss in account currency double point_value = tick_value / tick_size; double sl_value = sl_points * _Point * point_value; // Calculate lot size double lot_size = risk_amount / sl_value; // Normalize to broker's lot step double lot_step = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_STEP); lot_size = MathFloor(lot_size / lot_step) * lot_step; // Apply broker limits double min_lot = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MIN); double max_lot = SymbolInfoDouble(_Symbol, SYMBOL_VOLUME_MAX); return MathMax(min_lot, MathMin(max_lot, lot_size)); }
Контрольный список развертывания и проверка
Верификация перед развертыванием
Перед запуском эксперта в реальной торговле выполните следующие важные этапы проверки:
1. Проверка идентичности признаков
# Python: Generate test vectors with known outputs import json # Create detailed test cases test_cases = [] for i in range(10): features = X_test[i] prediction = clf.predict_proba([features])[0] test_cases.append({ 'test_id': i, 'features': features.tolist(), 'expected_prob_sell': float(prediction[0]), 'expected_prob_buy': float(prediction[1]), 'expected_class': int(clf.predict([features])[0]), 'tolerance': 1e-4 }) with open('mql5_validation_tests.json', 'w') as f: json.dump(test_cases, f, indent=2) print(f"Generated {len(test_cases)} test cases for MQL5 validation")
2. Создание валидационного скрипта в MQL5
//+------------------------------------------------------------------+ //| ValidationScript.mq5 | //| Validates ONNX model predictions against Python test cases | //+------------------------------------------------------------------+ #property script_show_inputs input string InpModelFile = "sequential_bagging_model.onnx"; void OnStart() { // Load model long model = OnnxCreateFromFile(InpModelFile, ONNX_DEFAULT); if(model == INVALID_HANDLE) { Print("Failed to load model"); return; } // Test case 1: Manually input features from Python double test_features[] = { -0.523, // bb_position 0.042, // bb_width -0.012, // return_1d -0.034, // return_5d 0.018, // volatility_20d 1.234, // volume_ratio 0.425, // rsi_14 (normalized) -1.823 // mean_reversion_z }; // Expected output from Python (copy from test file) double expected_prob_sell = 0.234; double expected_prob_buy = 0.766; // Run prediction matrix input(1, 8); for(int i=0; i<8; i++) { input[0][i] = (float)test_features[i]; } matrix output; OnnxRun(model, ONNX_NO_CONVERSION, input, output); double mql5_prob_sell = output[0][0]; double mql5_prob_buy = output[0][1]; // Validate double tolerance = 0.0001; bool sell_match = MathAbs(mql5_prob_sell - expected_prob_sell) < tolerance; bool buy_match = MathAbs(mql5_prob_buy - expected_prob_buy) < tolerance; Print("========== VALIDATION RESULTS =========="); Print("Expected SELL prob: ", expected_prob_sell); Print("MQL5 SELL prob: ", mql5_prob_sell); Print("Difference: ", MathAbs(mql5_prob_sell - expected_prob_sell)); Print("Match: ", sell_match ? "✓ PASS" : "✗ FAIL"); Print(""); Print("Expected BUY prob: ", expected_prob_buy); Print("MQL5 BUY prob: ", mql5_prob_buy); Print("Difference: ", MathAbs(mql5_prob_buy - expected_prob_buy)); Print("Match: ", buy_match ? "✓ PASS" : "✗ FAIL"); Print("========================================"); if(sell_match && buy_match) { Print("✓✓✓ VALIDATION PASSED ✓✓✓"); } else { Print("✗✗✗ VALIDATION FAILED ✗✗✗"); Print("Check feature calculations!"); } OnnxRelease(model); }
Типичные проблемы при развертывании и способы их решения
| Проблема | Симптом | Решение |
|---|---|---|
| Несоответствие признаков | Прогнозы отличаются от Python более чем на 1% | Используйте валидационный скрипт. Проверьте порядок вычислений, периоды ретроспективы и обработку деления на ноль |
| Ошибка загрузки модели | INVALID_HANDLE при вызове OnnxCreateFromFile | Убедитесь, что файл находится в папке MQL5/Files/, проверьте правильность написания имени файла, убедитесь в совместимости версий opset (9–15) |
| Неправильная форма входных данных | OnnxRun возвращает false | Проверьте, что количество признаков соответствует обучению. Используйте OnnxGetInputShape для проверки ожидаемых размерностей |
| Медленные прогнозы | Эксперт отстает на каждом тике | Уменьшите n_estimators, упростите деревья (уменьшите max_depth) или выполняйте прогнозы только на новых барах |
| Ошибка с индексацией массива | Предупреждения ArraySetAsSeries | Всегда вызывайте ArraySetAsSeries(array, true) перед операциями CopyClose / CopyHigh / CopyLow |
Панель мониторинга для производства
Добавьте этот код для отслеживания производительности модели в реальном времени:
//--- Add to global variables section struct PredictionStats { int total_predictions; int buy_signals; int sell_signals; double avg_confidence; double max_confidence; double min_confidence; } g_stats; //--- Add to OnInit() void ResetStats() { g_stats.total_predictions = 0; g_stats.buy_signals = 0; g_stats.sell_signals = 0; g_stats.avg_confidence = 0.0; g_stats.max_confidence = 0.0; g_stats.min_confidence = 1.0; } //--- Add after model prediction in OnTick() void UpdateStats(double prob_sell, double prob_buy) { g_stats.total_predictions++; double max_prob = MathMax(prob_sell, prob_buy); if(prob_buy > InpConfidenceThreshold) g_stats.buy_signals++; if(prob_sell > InpConfidenceThreshold) g_stats.sell_signals++; g_stats.avg_confidence = (g_stats.avg_confidence * (g_stats.total_predictions - 1) + max_prob) / g_stats.total_predictions; g_stats.max_confidence = MathMax(g_stats.max_confidence, max_prob); g_stats.min_confidence = MathMin(g_stats.min_confidence, max_prob); } //--- Enhanced Comment() display Comment(StringFormat( "=== Sequential Bootstrap EA ===\n" + "Time: %s\n\n" + "Current Prediction:\n" + " SELL: %.2f%% %s\n" + " BUY: %.2f%% %s\n\n" + "Statistics (Session):\n" + " Predictions: %d\n" + " BUY signals: %d\n" + " SELL signals: %d\n" + " Avg confidence: %.2f%%\n" + " Range: %.2f%% - %.2f%%\n\n" + "Positions: %d / %d", TimeToString(TimeCurrent(), TIME_DATE|TIME_MINUTES), prob_sell * 100, prob_sell > InpConfidenceThreshold ? "[SIGNAL]" : "", prob_buy * 100, prob_buy > InpConfidenceThreshold ? "[SIGNAL]" : "", g_stats.total_predictions, g_stats.buy_signals, g_stats.sell_signals, g_stats.avg_confidence * 100, g_stats.min_confidence * 100, g_stats.max_confidence * 100, PositionsTotal(), InpMaxTrades ));
Оптимизация производительности для производства
Оптимизация размера модели
Для реальной торговли предпочтительнее модели меньшего размера с сопоставимой производительностью:
# Option 1: Train a smaller production model clf_prod = SequentiallyBootstrappedBaggingClassifier( samples_info_sets=samples_info_sets, price_bars_index=price_bars.index, estimator=DecisionTreeClassifier( max_depth=4, # Reduced from 6 min_samples_leaf=100 # Increased from 50 ), n_estimators=50, # Reduced from 100 max_samples=0.5, random_state=42 ) clf_prod.fit(X_train, y_train) # Compare performance print("Full model test accuracy:", clf.score(X_test, y_test)) print("Production model test accuracy:", clf_prod.score(X_test, y_test)) # Option 2: Feature selection to reduce input dimensionality from sklearn.feature_selection import SelectKBest, f_classif selector = SelectKBest(f_classif, k=6) # Keep only 6 best features X_train_selected = selector.fit_transform(X_train, y_train) X_test_selected = selector.transform(X_test) # Train on reduced features clf_reduced = SequentiallyBootstrappedBaggingClassifier( samples_info_sets=samples_info_sets, price_bars_index=price_bars.index, n_estimators=50, random_state=42 ) clf_reduced.fit(X_train_selected, y_train) # Show which features were selected selected_features = selector.get_support(indices=True) print("Selected feature indices:", selected_features) print("Reduced model accuracy:", clf_reduced.score(X_test_selected, y_test))
Альтернативное развертывание: REST API
Если ограничения ONNX становятся критическими (например, при необходимости сложной предобработки или частого обновления моделей), REST API обеспечивает большую гибкость:
# Python: Simple Flask API from flask import Flask, request, jsonify import joblib import numpy as np app = Flask(__name__) model = joblib.load('sequential_bagging_model.pkl') @app.route('/predict', methods=['POST']) def predict(): try: features = np.array(request.json['features']).reshape(1, -1) proba = model.predict_proba(features)[0] return jsonify({ 'success': True, 'probability_sell': float(proba[0]), 'probability_buy': float(proba[1]), 'model_version': 'v1.0' }) except Exception as e: return jsonify({'success': False, 'error': str(e)}), 400 if __name__ == '__main__': app.run(host='0.0.0.0', port=5000)
//--- MQL5: HTTP client for REST API #include <JAson.mqh> // Or your preferred JSON library bool PredictViaAPI(const double &features[], double &prob_sell, double &prob_buy) { string url = "http://localhost:5000/predict"; // Build JSON request string json_request = "{"; json_request += "\"features\":["; for(int i=0; i<ArraySize(features); i++) { json_request += DoubleToString(features[i], 6); if(i < ArraySize(features)-1) json_request += ","; } json_request += "]}"; // Send HTTP POST request char post_data[]; char result_data[]; string headers = "Content-Type: application/json\r\n"; StringToCharArray(json_request, post_data, 0, WHOLE_ARRAY, CP_UTF8); int res = WebRequest("POST", url, headers, 5000, post_data, result_data, headers); if(res == -1) { Print("API request failed: ", GetLastError()); return false; } // Parse JSON response string response = CharArrayToString(result_data, 0, WHOLE_ARRAY, CP_UTF8); // Parse using your JSON library // prob_sell = parsed_value; // prob_buy = parsed_value; return true; }
Компромиссы REST API:
| Аспект | ONNX (рекомендуется) | REST API |
|---|---|---|
| Задержка | ~1 мс | ~10–50 мс |
| Сложность | Низкая (самодостаточное решение) | Средняя (требуется сервер) |
| Обновления | Ручная замена файла | Горячая перезагрузка возможна |
| Предобработка | Ограничена (должна быть воспроизведена в MQL5) | Полная экосистема Python |
| Инфраструктура | Не требуется | Веб-сервер + мониторинг |
Сводка лучших практик
Ключевые факторы успеха:
- Идентичность признаков имеет первостепенное значение – Используйте валидационные скрипты для проверки точного соответствия признаков в MQL5 и Python. Даже небольшие расхождения накапливаются при прогнозировании ансамбля
- Документируйте все – Сохраняйте метаданные признаков, тестовые примеры и версии моделей. Ваше будущее "я" скажет вам спасибо
- Начинайте с консервативных настроек – Начинайте с небольших ансамблей (50 оценщиков) и более простых деревьев (max_depth=4–6) для более быстрых итераций
- Тестируйте поэтапно – Валидация → Торговля на бумаге → Небольшая реальная позиция → Полное развертывание
- Мониторьте непрерывно – Отслеживайте уверенность прогнозов, частоту сигналов и сравнивайте реальную производительность с ожиданиями бэктеста
Рабочий процесс обновления модели:
# Step 1: Train new model with updated data clf_v2 = SequentiallyBootstrappedBaggingClassifier(...) clf_v2.fit(X_train_updated, y_train_updated) # Step 2: Validate against held-out test set score_v2 = clf_v2.score(X_test, y_test) assert score_v2 >= previous_score * 0.95, "New model performs worse!" # Step 3: Export with version tag model_file = f"sequential_bagging_v2_{datetime.now().strftime('%Y%m%d')}.onnx" onnx_model = convert_sklearn(clf_v2, initial_types=initial_type) with open(model_file, "wb") as f: f.write(onnx_model.SerializeToString()) # Step 4: Run backtests comparing v1 vs v2 # Step 5: Deploy to paper trading first # Step 6: Monitor for 1-2 weeks before live deployment # Step 7: Keep v1 as fallback
Когда переобучать:
- Регулярный график – Ежемесячное или ежеквартальное переобучение на расширенном наборе данных
- Снижение производительности – Если точность в реальной торговле падает на 10% и более по сравнению с ожиданиями бэктеста
- Смена рыночного режима – Значительные изменения волатильности, корреляций или структуры рынка
- Добавление признаков – При внедрении новых технических индикаторов или источников данных
Руководство по устранению неполадок
Проблема: Прогнозы случайны (все около 0.5)
Диагностика:
# Check if features have variance print("Feature statistics:") print(pd.DataFrame(X_train).describe()) # Check class balance print("Class distribution:", np.bincount(y_train)) # Verify model actually learned something print("Training accuracy:", clf.score(X_train, y_train)) print("Test accuracy:", clf.score(X_test, y_test))
Решение
- Убедитесь, что признаки не являются нулевыми или постоянными
- Проверьте наличие серьезного дисбаланса классов (рассмотрите использование sample_weight)
- Убедитесь, что модель сошлась во время обучения
- Увеличьте n_estimators или глубину деревьев в случае недообучения
Проблема: Прогнозы в MQL5 значительно отличаются от прогнозов в Python
Системный подход к отладке:
# 1. Print raw feature values from both systems # Python: print("Python features:", X_test[0]) # MQL5: Add to EA // Print all features before prediction string feat_str = ""; for(int i=0; i<ArraySize(feature_array); i++) { feat_str += StringFormat("[%d]:%.6f ", i, feature_array[i]); } Print("MQL5 features: ", feat_str); # 2. Check intermediate calculations # Add debug prints to FeatureEngine.mqh for BB, RSI, etc. Print("BB Upper:", bb_upper, " Middle:", bb_middle, " Lower:", bb_lower); Print("RSI:", rsi_value); # 3. Verify data alignment # Ensure MQL5 arrays are time-series ordered (most recent first) # Python typically uses oldest first
Проблема: Модель загружается медленно или эксперт зависает
Стратегии оптимизации:
// 1. Load model once in OnInit, not on every tick // ✓ Correct: int OnInit() { g_model_handle = OnnxCreateFromFile(InpModelFile, ONNX_DEFAULT); } // ✗ Wrong: void OnTick() { long model = OnnxCreateFromFile(InpModelFile, ONNX_DEFAULT); // DON'T DO THIS! } // 2. Reduce model complexity # Python: Train lighter model clf_fast = SequentiallyBootstrappedBaggingClassifier( n_estimators=30, # Reduced from 100 max_depth=3 # Reduced from 6 ) // 3. Predict only on new bar, not every tick datetime current_bar = iTime(_Symbol, _Period, 0); if(current_bar == g_last_bar_time) return; g_last_bar_time = current_bar;
Реальный пример развертывания
Ниже приведен успешно реализованный график развертывания в производстве:
| Неделя | Действие | Критерий успеха |
|---|---|---|
| 1 | Обучение модели, экспорт в ONNX, проверка совпадения прогнозов с Python | Максимальное расхождение прогнозов < 0.01% |
| 2 | Бэктест в стратегическом тестере на исторических данных за 2+ года | Коэффициент Шарпа > 1.5, максимальная просадка < 15% |
| 3 | Форвардный тест на демо-счете с полным размером позиций | Выполнено 20+ сделок, отсутствие технических ошибок |
| 4-5 | Реальная торговля с 10% от целевого капитала | Производительность в пределах 20% от ожиданий бэктеста |
| 6-8 | Постепенное увеличение до 50%, затем до 100% от целевого капитала | Стабильная производительность, отсутствие неожиданного поведения |
| 9+ | Полное развертывание с ежемесячным анализом производительности | Ежемесячное переобучение, ежеквартальная оценка моделей |
Заключение: Контрольный список развертывания ONNX
Перед запуском вашей последовательной бутстреп-модели в MQL5:
Предварительная подготовка (Python):
- ☐ Модель достигает приемлемой производительности на вневыборочных данных
- ☐ Экспорт в ONNX выполнен успешно (skl2onnx)
- ☐ Прогнозы ONNX проверены и совпадают с исходной моделью
- ☐ Документированы метаданные признаков (названия, порядок, вычисления)
- ☐ Созданы тестовые примеры с известными входными и выходными данными
- ☐ Файл модели имеет версию и зарезервированную копию
Реализация (MQL5):
- ☐ FeatureEngine.mqh точно соответствует вычислениям в Python
- ☐ Валидационный скрипт проходит все тестовые примеры
- ☐ Модель успешно загружается в OnInit
- ☐ Прогнозы выполняются без ошибок
- ☐ Настроены параметры управления рисками
- ☐ Реализованы логирование и мониторинг
Тестирование:
- ☐ Бэктест в стратегическом тестере выполнен (2+ года)
- ☐ Форвардный тест на демо-счете выполнен (2+ недели)
- ☐ Метрики производительности находятся в приемлемых диапазонах
- ☐ Обработаны краевые случаи (нулевой объем, рыночные разрывы и т.д.)
Производство:
- ☐ Начать с минимального капитала (10%)
- ☐ Ежедневный мониторинг в течение первых 2 недель
- ☐ Еженедельный анализ производительности
- ☐ Установлен график обновления моделей
- ☐ Документирована процедура резервного варианта
Благодаря тому, что последовательный бутстреп устраняет временное засорение (temporal leakage) при обучении, а ONNX обеспечивает надежное развертывание в MQL5, вы теперь имеете полный конвейер от исследований до производства. Такое сочетание гарантирует, что надежное обучение вашей модели преобразуется в достоверную производительность при реальной торговле.
Вложения
| Название файла | Описание |
|---|---|
| bootstrap_mc.py | Содержит код Монте-Карло для сравнения эффективности стандартного и последовательного бутстрепа. Генерирует случайные временные ряды и проводит эксперименты для измерения метрик уникальности для обоих методов. |
| bootstrapping.py | Основная реализация алгоритмов последовательного бутстрепа. Включает функции для создания матриц индикаторов, вычисления оценок уникальности и выполнения оптимизированной последовательной выборки с использованием Numba. |
| misc.py | Набор вспомогательных функций, включая форматирование данных, оптимизацию памяти, декораторы логирования, мониторинг производительности, вспомогательные функции времени и утилиты для конвертации файлов. |
| multiprocess.py | Реализует утилиты параллельной обработки для эффективных вычислений. Содержит функции для разделения задач, индикации прогресса и параллельного выполнения на нескольких ядрах процессора. |
| sb_bagging.py | Реализует классификатор и регрессор с последовательным бутстреп-бэггингом — ансамблевые методы, интегрирующие последовательную бутстреп-выборку с фреймворком бэггинга scikit-learn для финансового машинного обучения. |
Перевод с английского произведен MetaQuotes Ltd.
Оригинальная статья: https://www.mql5.com/en/articles/20059
Предупреждение: все права на данные материалы принадлежат MetaQuotes Ltd. Полная или частичная перепечатка запрещена.
Данная статья написана пользователем сайта и отражает его личную точку зрения. Компания MetaQuotes Ltd не несет ответственности за достоверность представленной информации, а также за возможные последствия использования описанных решений, стратегий или рекомендаций.
Создание самооптимизирующихся советников на MQL5 (Часть 8): Анализ нескольких стратегий (2)
Нейросети в трейдинге: Многодоменная архитектура анализа финансовых данных (MDL)
Оптимизация и форвард-анализ стратегий (Часть 1): Метод Пардо — базовая модель
Автоматизация торговых стратегий на MQL5 (Часть 24): Система торговли на пробое лондонской сессии с риск-менеджментом и трейлинг-стопами
- Бесплатные приложения для трейдинга
- 8 000+ сигналов для копирования
- Экономические новости для анализа финансовых рынков
Вы принимаете политику сайта и условия использования