Архитектура машинного обучения для MetaTrader 5 (Часть 8): Байесовская оптимизация гиперпараметров с Purged Cross-Validation и ранним отсечением испытаний
Оглавление
- Проблема стандартной HPO в финансовом ML
- Optuna — архитектура и основные понятия
- Контракт данных и _WeightedEstimator
- FinancialModelSuggester
- Целевая функция — Purged K-Fold с отсечением
- Финансово-ориентированное отсечение
- Оркестрация и хранение
- От Optuna Study к scikit-learn cv_results_
- Визуализация
- Практические соображения
- Заключение
- Прикрепленные файлы
Введение
Вы строите финансовый ML-классификатор на triple-barrier или meta-labels и должны честно выбрать гиперпараметры — не превращая сам поиск в фабрику переобучения. Стандартные инструменты не подходят для этого сценария по трем конкретным причинам. GridSearchCV и RandomizedSearchCV не учатся на прошлых испытаниях, поэтому тратят бюджет, повторно исследуя области пространства гиперпараметров, которые уже показали себя плохими. Они не могут остановить бесперспективную конфигурацию после первого дорогого фолда PurgedKFold; поэтому каждое испытание вынуждено проходить все фолды целиком, даже когда первый фолд уже сигнализирует о неудаче. Кроме того, они плохо интегрируются с финансовым контрактом данных — PurgedKFold как единственный допустимый разделитель, отдельные веса для обучения и оценки, а также постоянное хранилище, чтобы долгий поиск переживал сбои и поддерживал параллельных работников. Измеримые симптомы: напрасные вычисления, смещенные out-of-sample сравнения из-за утечки информации через границу purge и хрупкие эксперименты, которые после любого прерывания нужно начинать с нуля.
В этой статье показана практическая замена: Optuna (TPE sampler + отсечение + хранилище SQLite), нативно связанная с финансовой кросс-валидацией и соглашениями о взвешивании, установленными в предыдущих частях этой серии. После прочтения у вас будет конкретная, практически применимая схема HPO, состоящий из пяти компонентов:
- целевая функция, которая выполняет PurgedKFold кросс-валидацию с оценкой, взвешенной по атрибуции доходности, и сообщает результаты на уровне фолдов для отсечения;
- FinancialModelSuggester, слой трансляции параметров, который преобразует спецификации распределений scikit-learn в вызовы trial.suggest_*() и одновременно оптимизирует схему взвешивания выборки и затухание;
- финансово-ориентированный механизм отсечения (TradingModelPruner), который применяет экономическую базовую линию на основе энтропии и допуск волатильности, масштабированный по режимам;
- оркестратор (optimize_trading_model) с хранилищем SQLite для возобновления и параллельных работников;
- конвертер из Study в форматcv_results_ для совместимости с существующей аналитикой scikit-learn.
Выходные артефакты — сохраненное Study, переобученный best_estimator_ (_WeightedEstimator оборачивает настроенную базовую модель), и cv_results_ DataFrame с оценками по фолдам — готовый для тех же последующих диагностик, которые используются в остальной части конвейера.
Эта статья является частью 8.1 серии MetaTrader 5 Machine Learning Blueprint. Предыдущие статьи построили компоненты, от которых зависит эта система: Часть 1 устранила утечку данных на уровне баров; Часть 2 ввела triple-barrier и meta-labels; Часть 3 добавила метки trend-scanning; Часть 4 рассмотрела конкуренцию меток и ввела веса средней уникальности; Часть 5 ввела sequential bootstrapping и установила соглашение об отдельных весах обучения и оценки; Часть 6 построила инфраструктуру кэширования; а Часть 7 собрала все в воспроизводимый производственный пайплайн. Интеграция компонентов этой статьи в production pipeline — включая обертку clf_hyper_fit и кэширование — рассматривается в части 8.2.
1. Проблема стандартной HPO в финансовом ML
1.1 Почему оптимизация гиперпараметров важнее в финансах
В большинстве областей ML разумно настроенная модель с хорошими признаками дает приемлемые результаты. В финансах отношение сигнал/шум низкое, нестационарность является нормой, а цена переобучения измеряется капиталом. Как подчеркивает Лопес де Прадо, каждая степень свободы в конвейере — это возможность переобучиться. Гиперпараметры являются степенями свободы, и метод их поиска определяет, будет ли итоговая конфигурация обобщать или запоминать.
Проблема усиливается структурой финансовых данных. Как было установлено в Части 4, метки triple-barrier перекрываются во времени. Возникающая конкуренция меток означает, что стандартная k-fold CV будет пропускать информацию через границу train-test, и любая полученная оценка будет оптимистично смещена. Инструмент HPO должен нативно работать с PurgedKFold для получения корректных сравнений между испытаниями.
1.2 Ограничения Scikit-Learn
GridSearchCV масштабируется экспоненциально: случайный лес с пятью значениями для каждого из четырех гиперпараметров дает 625 комбинаций, то есть 3 125 обучений модели при 5-fold CV. RandomizedSearchCV снижает стоимость, но остается неинформированным — каждое испытание независимо. Ни один из них не поддерживает раннюю остановку, межиспытательное обучение или постоянное хранилище.
| Ограничение | Влияние на финансовый ML | |
|---|---|---|
| 1. | Нет обучения между испытаниями | Вычисления тратятся на плохие области пространства гиперпараметров |
| 2. | Нет ранней остановки плохих испытаний | Все фолды выполняются, даже когда ранние фолды уже показывают плохие оценки |
| 3. | Нет постоянного хранилища | Прерванные поиски нельзя возобновить; сравнение между экспериментами требует ручного учета |
| 4. | Жесткая интеграция CV | Отчетность на уровне фолдов для отсечения требует обходных решений, нарушающих стандартный интерфейс CV |
1.3 Критически важная граница: HPO и оптимизация стратегии
Прежде чем продолжить, нужно четко провести одну границу. Интеллектуальный поиск уместен для HPO и вреден для оптимизации параметров стратегии. Тимоти Мастерс точно определяет это: TPE sampler или генетический алгоритм эффективно находит глобальный оптимум in-sample поверхности пригодности — но в финансовых данных эта поверхность в значительной степени состоит из шума. Чем эффективнее вы ее ищете, тем сильнее переобучается результат.
Правило простое: если ваша целевая функция — это статистическая мера на размеченных данных (кросс-валидированная accuracy, log-loss на triple-barrier labels), используйте Optuna. Если это финансовая мера на исторических кривых капитала (коэффициент Шарпа, просадка), не используйте. Преимущество Optuna в HPO — то же свойство, которое делает ее опасной для оптимизации стратегии: она слишком надежно находит оптимум.
2. Optuna — архитектура и основные понятия
2.1 Как работает Optuna
Optuna построена вокруг трех понятий:
- Study: Полная сессия оптимизации с направлением (максимизировать или минимизировать), которая хранит все результаты. При хранилище SQLite study сохраняется между Python-сессиями — именно тот тип воспроизводимости с учетом времени, который был установлен в Части 6.
- Испытание: Одна оценка одной конфигурации гиперпараметров. Каждое испытание предлагает значения, вычисляет целевую функцию и сообщает результат.
- Sampler: Алгоритм, выбирающий, какие значения пробовать дальше. По умолчанию используется TPE (Tree-structured Parzen Estimator), байесовский метод оптимизации, который учится на завершенных испытаниях.
Ключевое отличие от инструментов scikit-learn: Optuna учится на завершенных испытаниях. TPE sampler моделирует связь между гиперпараметрами и целевой функцией, постепенно фокусируясь на перспективных областях. Каждое следующее испытание с большей вероятностью исследует конфигурации рядом с уже известными хорошими результатами.
2.2 Отсечение: HyperbandPruner
Отсечение останавливает испытание до завершения, если промежуточные результаты показывают, что оно не будет конкурентоспособным. В 5-fold purged CV, если первый фолд дает плохую оценку, оставшиеся фолды можно пропустить — напрямую снижая общий объем вычислений.
HyperbandPruner — правильный безусловный выбор для финансовой CV. При всего 5–10 фолдах на испытание большинству pruner-ов — которые работают внутри испытания — почти не с чем работать. Hyperband работает между испытаниями. Вместо вопроса «плохо ли это испытание работает по сравнению со всеми остальными на этом шаге?» Hyperband задает вопрос распределения ресурсов: «при фиксированном вычислительном бюджете какие испытания заслуживают больше фолдов?»
Как работают brackets
Используя точные параметры из кода — min_resource=1, max_resource=5 (для cv=5), reduction_factor=3 (η=3) — число brackets равно:
s_max = ⌊log₃(5/1)⌋ = ⌊1.46⌋ = 1
Итак, есть два brackets, s=0 и s=1, работающие одновременно в рамках бюджета испытаний. Думайте о них как о двух параллельных гонках.
Bracket s=1 — агрессивный bracket: входят три испытания. Каждое оценивается только на фолде 1. Лучший ⌈3/3⌉ = 1 выживший продолжает фолды 2–5. Два других отсекаются после одной оценки фолда. Всего оценок фолдов: 3×1 + 1×4 = 7.
Bracket s=0 — консервативный bracket: входят два испытания, и оба безусловно проходят до фолда 5. В этом bracket отсечение не срабатывает независимо от промежуточных оценок. Всего оценок фолдов: 2×5 = 10.
Консервативный bracket — не запасной вариант, а гарантированная страховка, работающая параллельно. Комбинация параметров, не обязательно покажет лучший результат уже после первого фолда (возможно, этот фолд покрывает кризисный период), но действительно является лучшей конфигурацией, всегда получит полную оценку через консервативный bracket. Именно это отличает Hyperband от SuccessiveHalvingPruner, который является просто bracket s=1 в изоляции и полностью отбросил бы такое испытание.
| Bracket s=1 (агрессивный) | Bracket s=0 (консервативный) | |
|---|---|---|
| Запущено испытаний | 3 | 2 |
| Отсечено после фолда 1 | 2 | 0 |
| Завершает все фолды | 1 | 2 |
| Всего оценок фолдов | 7 | 10 |
Рис. 1 — Структура brackets HyperbandPruner
Стоит ли создавать пользовательский механизм отсечения поверх HyperbandPruner?
Нет — и обертка поверх него нарушит его работу. HyperbandPruner управляет внутренним состоянием через назначения brackets при создании испытания. Когда вызывается trial.should_prune(), Hyperband проверяет, к какому bracket принадлежит испытание, на каком rung он находится и попадает ли он ниже верхних 1/η испытаний на этом rung Это система управления турниром с состоянием, а не простая проверка порога. Наследование и добавление финансовых правил в prune() создает два режима отказа: ваши правила могут убить trial, который Hyperband предназначил для консервативного bracket (ломая гарантию полной оценки), а записи rung в Hyperband станут несогласованными, потому что trial, отсеченный вашим правилом, никогда не сравнивался на запланированном rung, что искажает медианные сравнения для всех последующих испытаний.
TradingModelPruner работает именно потому, что оборачивает MedianPruner, у которого нет bracket-состояния, которое можно испортить. Правильный способ добавить финансовую доменную логику при использовании HyperbandPruner — поместить проверки внутрь целевой функции перед вызовом trial.should_prune():
for fold_idx, (train_idx, val_idx) in enumerate(cv.split(X, y)): # ... fit and score ... fold_scores.append(score) # 1. Financial domain check fires first — before Hyperband's bracket logic if score < min_score_threshold: trial.set_user_attr("pruned_reason", "below_baseline") raise TrialPruned(f"Below economic baseline at fold {fold_idx}") # 2. Hyperband bracket management — rung comparison across trials trial.report(score, step=fold_idx) if trial.should_prune(): raise TrialPruned()
Финансовая логика срабатывает до сообщения результата в Hyperband, поэтому испытание просто не попадает на этот уровень сравнения, и внутреннее состояние Hyperband остается согласованным. Эти два механизма чисто разделены.
from optuna.pruners import HyperbandPruner pruner = HyperbandPruner( min_resource=1, max_resource=n_splits, reduction_factor=3, )
2.3 API Define-by-Run
Optuna задает пространство поиска внутри целевой функции. Это позволяет использовать условные пространства гиперпараметров и дает FinancialModelSuggester возможность добавить weight_scheme, weight_decay и weight_linear вместе с параметрами базовой модели, совместно оптимизируя стратегию взвешивания с моделью.
2.4 Постоянное хранилище и возобновляемость
Optuna хранит результаты study в SQLite (или PostgreSQL, MySQL). Сбойный или ограниченный по времени запуск возобновляется с последнего завершенного испытания через load_if_exists=True. Это соответствует принципу воспроизводимости, установленному в Части 7: каждый эксперимент должен быть возобновляемым, проверяемым и давать идентичные результаты при повторном запуске. Несколько параллельных процессов могут выполнять испытания параллельно в одном исследовании, а 30-секундный SQLite timeout предотвращает конфликты блокировок.
3. Контракт данных и _WeightedEstimator
Все компоненты этой системы используют общий контракт данных, унаследованный от production pipeline, установленного в Части 7:
- X: Feature DataFrame с DatetimeIndex, созданный конвейером инженерии признаков.
- y: Target Series, выровненный с X — triple-barrier или trend-scanning labels из Части 2 и Части 3.
- events: DataFrame, индексированный временем события, содержащий как минимум: t1 (время касания барьера), w (вес атрибуции доходности |return|), и tW (вес на основе уникальности из Части 4).
- data_index: DatetimeIndex полного набора баров — используется _WeightedEstimator для вычисления весов по правильной вселенной баров.
3.1 Две различные роли весов
Перед описанием кода обработка весов требует явного рассмотрения, потому что примеры Прадо смешивают две величины, служащие разным целям. Это различие было предвосхищено в Части 5, где был введен ml_cross_val_scores_all с отдельными параметрами sample_weight_train и sample_weight_score.
Веса средней уникальности (events['tW']) отвечают на вопрос: «сколько независимой информации вносит это наблюдение?» Их место — в estimator.fit(), чтобы модель не переоценивала неявно избыточные перекрывающиеся наблюдения — проблему конкуренции меток, выявленную в Части 4.
Веса атрибуции доходности (events['w']) отвечают на вопрос: «насколько корректный или ошибочный прогноз по этому наблюдению важен для P&L?» Их место — в валидирующей функции оценки. Без них метрика CV считает прогноз на плоский день столь же важным, как прогноз в день крупного движения, незаметно искажая сравнения между испытаниями.
# Fitting: _WeightedEstimator applies tW internally via scheme='uniqueness' fit = clone(model).fit(X_train, y_train) # Scoring: return-attribution weights from events['w'] w_val = events['w'].iloc[val_idx].to_numpy() score = -log_loss(y_val, y_prob, sample_weight=w_val)
Прадо вычисляет один комбинированный вес (уникальность × |return|) и использует его и для обучения, и для оценки. Это разумное приближение. Использование отдельных весов, как выше, строго корректнее, потому что каждый вес выполняет подходящую ему работу. Более того, сама схема взвешивания — unweighted, uniqueness или return — это гиперпараметр, который FinancialModelSuggester оптимизирует совместно с параметрами модели.
Важное замечание о типе стратегии: trend-following модели могут выиграть от весов return-attribution для fitting вместо весов уникальности. Средняя уникальность штрафует перекрытие меток. На трендовых рынках метки по замыслу длинные и сильно перекрываются, поэтому трендовая модель, обученная с весами уникальности, фактически начинает недооценивать именно те условия, которые должна использовать. Веса атрибуции доходности не учитывают перекрытие — они просто повышают вес крупных движений. Гиперпараметр weight_scheme делает это измерение доступным для поиска.
3.2 _WeightedEstimator
_WeightedEstimator — совместимая со sklearn обертка (из afml.production.model_development) которая полностью инкапсулирует вычисление весов. Она принимает базовый estimator, events DataFrame, полный баровый data_index, и параметры схемы взвешивания, выбранные Optuna. Когда fit(X_train, y_train) вызывается без явного аргумента sample_weight, _WeightedEstimator внутренне вычисляет соответствующие веса с использованием data_index и events, затем передает их базовому estimator. Такая конструкция убирает вычисление весов из цикла целевой функции и делает схему взвешивания гиперпараметром первого класса.
4. FinancialModelSuggester
FinancialModelSuggester преобразует распределения параметров в стиле scikit-learn в вызовы trial.suggest_*() и конфигурирует _WeightedEstimator с выбранными параметрами. Он предоставляет два метода, образующие двойственную пару: (1) suggest_and_apply для стохастического использования внутри objective, и (2) apply_from_params для детерминированного восстановления из study.best_params. Центральный реестр WEIGHT_KEYS отделяет гиперпараметры весов от гиперпараметров модели в обоих методах, гарантируя, что шаг проверки в apply_from_params проверяет weight keys по принятым параметрам базовой модели только там, где это уместно.
class FinancialModelSuggester: """ Translates Scikit-Learn style distribution dictionaries into Optuna trial suggestions for rigorous statistical HPT. Two core methods form a dual pair: suggest_and_apply — Trial → params → model (stochastic, used in objective) apply_from_params — params → model (deterministic, used for refit) """ # Central registry: separates weight keys from model keys in both methods WEIGHT_KEYS = frozenset({"weight_scheme", "weight_decay", "weight_linear"}) @classmethod def suggest_and_apply( cls, trial: optuna.Trial, base_model, param_distributions: dict, events: pd.DataFrame, data_index: pd.DatetimeIndex, ): # 1. Suggest weight hyperparameters — optimised jointly with the model scheme = trial.suggest_categorical("weight_scheme", ["unweighted", "uniqueness", "return"]) decay = trial.suggest_float("weight_decay", 0.1, 1.0) linear = trial.suggest_categorical("weight_linear", [True, False]) # 2. Suggest base model hyperparameters from scikit-learn-style distributions sampled_params = {} for name, dist in param_distributions.items(): if isinstance(dist, list): sampled_params[name] = trial.suggest_categorical(name, dist) elif hasattr(dist, 'ppf'): # scipy.stats distribution low, high = dist.support() if dist.dist.name == "randint": sampled_params[name] = trial.suggest_int(name, int(low), int(high)) else: is_log = dist.dist.name in ['reciprocal', 'loguniform'] sampled_params[name] = trial.suggest_float(name, low, high, log=is_log) elif isinstance(dist, range): try: sampled_params[name] = trial.suggest_int(name, dist.start, dist.stop - 1) except AttributeError: low, high = dist.support() sampled_params[name] = trial.suggest_int(name, int(low), int(high)) else: sampled_params[name] = dist # 3. Clone and configure — _WeightedEstimator handles weight computation internally new_base = clone(base_model) new_base.set_params(**sampled_params) return _WeightedEstimator( base_estimator=new_base, events=events, data_index=data_index, scheme=scheme, decay=decay, linear=linear, ) @classmethod def apply_from_params( cls, params: dict, base_model, events: pd.DataFrame, data_index: pd.DatetimeIndex, ) -> "_WeightedEstimator": """ Reconstruct a WeightedEstimator from a flat params dict (e.g. study.best_params). Validates model params against the base model's accepted parameter set. """ weight_params = {k: params[k] for k in cls.WEIGHT_KEYS if k in params} model_params = {k: v for k, v in params.items() if k not in cls.WEIGHT_KEYS} # Defensive validation: catch invalid params before refit valid_keys = set(base_model.get_params().keys()) invalid = set(model_params) - valid_keys if invalid: raise ValueError( f"Parameters {invalid} are not valid for " f"{type(base_model).__name__}. Valid: {sorted(valid_keys)}" ) new_base = clone(base_model) new_base.set_params(**model_params) return _WeightedEstimator( base_estimator=new_base, events=events, data_index=data_index, scheme=weight_params.get("weight_scheme", "unweighted"), decay=weight_params.get("weight_decay", 1.0), linear=weight_params.get("weight_linear", False), ) @classmethod def get_search_space(cls, model_name: str): """ Returns curated parameter distributions for financial models. Note min_weight_fraction_leaf for random forests: requires each leaf to account for at least a fraction of total sample weight. This interacts directly with return-attribution weights and is a stronger regulariser than min_samples_leaf when weights vary widely across market regimes. """ spaces = { "random_forest": { "n_estimators": range(100, 1000), "max_depth": range(3, 7), "min_weight_fraction_leaf": stats.uniform(0.025, 0.1), "max_features": ["sqrt", "log2", 0.5, 1.0], "ccp_alpha": stats.loguniform(1e-5, 1e-2), }, "xgboost": { "n_estimators": range(100, 1000), "learning_rate": stats.loguniform(1e-3, 0.1), "max_depth": range(2, 8), "subsample": stats.uniform(0.6, 0.4), "colsample_bytree": stats.uniform(0.6, 0.4), "gamma": stats.uniform(0, 5), }, } return spaces.get(model_name.lower(), {})
5. Целевая функция — Purged K-Fold с отсечением
Целевая функция — точка интеграции. Она выполняет PurgedKFold кросс-валидацию (введенную в Части 4), применяет веса атрибуции доходности к валидационной метрике и сообщает оценки на уровне фолдов в HyperbandPruner после каждого фолда через trial.report(). Такая прямая отчетность на уровне фолдов — ключевая возможность, которую GridSearchCV не может предоставить без существенных обходных решений.
Стоит отметить две детали. Во-первых, clone(model).fit(X_train, y_train) без явного аргумента sample_weight корректен — _WeightedEstimator вычисляет и применяет training weights внутренне на основе scheme, выбранной для этого trial. Во-вторых, w_val из events['w'] всегда является весом атрибуции доходности для scoring, независимо от выбранной схемы обучения, потому что мы всегда оцениваем прогнозы по экономической значимости. Это разделение отражает соглашение sample_weight_train / sample_weight_score из ml_cross_val_scores_all в Части 5.
def optimize_trading_model_with_pruning( trial: optuna.Trial, X, y, events, data_index, classifier, param_distributions: dict, n_splits: int = 5, metric: str = "neg_log_loss", ): """ Objective function for tuning models using Purged K-Fold cross-validation. Uses separate weights for fitting (_WeightedEstimator) and scoring (events['w']), mirroring the sample_weight_train / sample_weight_score convention from Part 5. """ suggester = FinancialModelSuggester() model = suggester.suggest_and_apply( trial, classifier, param_distributions, events, data_index ) t1 = events['t1'] cv = PurgedKFold(n_splits=n_splits, t1=t1, pct_embargo=0.01) # Convert once before the loop — avoids repeated pandas overhead per fold # and ensures val_idx (numpy integer array from PurgedKFold) indexes correctly w_score = events['w'].to_numpy() fold_scores = [] for fold_idx, (train_idx, val_idx) in enumerate(cv.split(X, y)): X_train, X_val = X.iloc[train_idx], X.iloc[val_idx] y_train, y_val = y.iloc[train_idx], y.iloc[val_idx] # _WeightedEstimator applies training weights (uniqueness or return) internally fit = clone(model).fit(X_train, y_train) # Slice pre-converted numpy array — no pandas overhead inside the hot loop w_val = w_score[val_idx] if metric == "neg_log_loss": y_prob = fit.predict_proba(X_val) score = -log_loss(y_val, y_prob, sample_weight=w_val) else: y_pred = fit.predict(X_val) score = f1_score(y_val, y_pred, sample_weight=w_val) fold_scores.append(score) # Financial baseline check fires before Hyperband's bracket logic # (only active when TradingModelPruner is not the pruner) trial.report(score, step=fold_idx) if trial.should_prune(): avg_score_so_far = np.mean(fold_scores) trial.set_user_attr("pruned_at_fold", fold_idx) trial.set_user_attr("score_when_pruned", avg_score_so_far) trial.set_user_attr("total_folds_attempted", len(fold_scores)) raise TrialPruned(f"Pruned at fold {fold_idx}. Avg: {avg_score_so_far:.4f}") final_score = np.mean(fold_scores) trial.set_user_attr("fold_scores", fold_scores) trial.set_user_attr("score_std", np.std(fold_scores)) return final_score
Оценки фолдов, сохраненные в trial.user_attrs["fold_scores"], — не просто диагностические метаданные. Они служат входом для callback’а check_for_overfitting и столбцов уровня фолдов в cv_results_ DataFrame. Высокий score_std на 5-fold purged split часто указывает, что модель захватывает режимно-специфичные паттерны, а не действительно обобщаемый сигнал — именно такую чувствительность к режимам призвана выявлять многорежимная выборка баров, обсуждавшаяся в Части 7.
6. Финансово-ориентированное отсечение
TradingModelPruner расширяет MedianPruner тремя финансово обоснованными правилами. Это не универсальная замена HyperbandPruner — у него есть конкретное окно применимости. Поскольку он оборачивает MedianPruner, он полностью молчит для первых n_startup_trials=10 завершенных trials и дает надежные сравнения только после еще 20–30. На коротком исследовании менее чем с 30 испытаниями он дает меньшую экономию, чем HyperbandPruner. Четыре условия, при которых он становится лучшим выбором, приведены ниже.
Большой бюджет испытаний на хорошо понятном инструменте. Запуск 100 или более испытаний на инструменте с известными характеристиками дает откалиброванное ожидание того, как выглядит разумный нижний уровень log-loss. Испытание, получающий оценку намного ниже этого уровня после фолда 1, — не режимно-чувствительный отстающий, а сломанная конфигурация. Энтропийный базовый порог отсекает такую конфигурацию сразу и без дополнительных условий. HyperbandPruner не может принять это решение, потому что сравнивает относительный ранг между trials, а не абсолютный экономический минимум. Пример: на EURUSD triple-barrier labels, откалиброванных к соотношению барьеров 3:1, log-loss стабильно находится около −0.65. Испытание с −0.92 после фолда 1 заслуживает немедленного завершения независимо от действий других испытаний.
Трендовый инструмент с высокой дисперсией return-weight. На сильно трендовом инструменте events['w'] имеет высокий коэффициент вариации — несколько наблюдений крупных движений имеют веса на порядок выше остальных. TradingModelPruner масштабирует volatility_tolerance пропорционально этому CV, то есть допускает большие колебания fold-score, не отсекая трендовую модель, которая случайно выглядит плохо на mean-reverting фолде. HyperbandPruner отсекает по относительному рангу независимо от того, отражает ли колебание качество модели или рыночную структуру. Пример: модель GBPJPY, обученная на недельных triple-barrier labels в трендовый период, покажет высокую дисперсию fold-score просто потому, что каждый фолд покрывает отдельную фазу momentum. TradingModelPruner учитывает это; HyperbandPruner может ее отбросить.
Окно CV, охватывающее известный перелом режима. Если обучающее окно охватывает структурный сдвиг — изменение политики центрального банка, изменение режима волатильности, изменение микроструктуры, — один фолд будет категорически отличаться от остальных. Модель, лучше всего работающая в обоих режимах, не обязательно будет выглядеть лучшей после фолда 1. TradingModelPruner с n_warmup_steps=2 дает льготный период в два фолда до срабатывания variance-based pruning. Пример: исследование, чье окно CV охватывает 2019–2022 годы, будет иметь один фолд с COVID-era volatility. Разрешение двух фолдов перед variance-based pruning не дает этому фолду устранить конфигурации, которые хорошо обобщаются между режимами.
Последующее исследование после начального запуска HyperbandPruner. Практический рабочий процесс: запустить начальное 50-последующее исследование с HyperbandPruner для установления ландшафта оценок, затем запустить сфокусированное 150-trial follow-up с TradingModelPruner. Первое study дает откалиброванный диапазон оценок для точной настройки multiplier. Второе study не дает TPE sampler — у которого теперь есть prior из первого запуска — возвращаться к конфигурациям, уже показавшим близость к noise floor.
Порог базовой энтропии использует распределение меток, взвешенное по атрибуции доходности, а не сырое распределение. Это корректная базовая линия, потому что наивный классификатор, всегда предсказывающий доминирующий класс, достигает этой энтропии, когда классы взвешены по экономической значимости. Допуск волатильности пропорционален коэффициенту вариации весов атрибуции доходности: трендовые рынки (высокий CV) получают более широкий допуск; mean-reverting рынки (низкий CV) — более узкий.
class TradingModelPruner(MedianPruner): """ Financial-aware pruner that adjusts thresholds based on label entropy and return-attribution weighted volatility. """ def __init__( self, y, sample_weight, # Return-attribution weights: events['w'] n_startup_trials: int = 10, n_warmup_steps: int = 2, multiplier: float = 1.15, ): super().__init__(n_startup_trials=n_startup_trials, n_warmup_steps=n_warmup_steps) # Baseline entropy from return-attribution weighted label distribution weighted_counts = pd.Series(sample_weight).groupby(y.values).sum() probs = weighted_counts / weighted_counts.sum() if set(y.unique()) != {0, 1}: self.baseline_entropy = -np.sum(probs * np.log(probs)) self.min_score_threshold = -self.baseline_entropy * multiplier else: majority_ratio = probs.max() self.min_score_threshold = majority_ratio / multiplier # Volatility tolerance scales with CV of weights: # trending regimes (high CV) → higher fold-score variance is acceptable weight_cv = np.std(sample_weight) / np.mean(sample_weight) self.volatility_tolerance = 0.1 * (1 + weight_cv) def prune(self, study, trial) -> bool: step = trial.last_step if step is None: return False if trial.number >= 5 and len(trial.intermediate_values) >= 3: # Rule 1: Worse than economically-weighted naive baseline? current_score = trial.intermediate_values.get(step) if trial.number > 1 and current_score < self.min_score_threshold: return True # Rule 2: Unstable across recent folds? recent_scores = list(trial.intermediate_values.values())[-3:] if np.std(recent_scores) > self.volatility_tolerance: return True # Rule 3: Standard median pruning return super().prune(study, trial)
Сводное правило выбора: n_trials < 30 → используйте HyperbandPruner; n_trials ≥ 100 и выполняется хотя бы одно из условий выше → используйте TradingModelPruner; первое study на неизвестном инструменте → используйте HyperbandPruner.
7. Оркестрация и хранение
optimize_trading_model создает study, подключает pruner и sampler, соединяется с SQLite storage и выполняет refit. HyperbandPruner является значением по умолчанию для pruner_type. Установка n_jobs=1 у classifier перед study и восстановление -1 после refit предотвращает oversubscription: при study.optimize с параллельными процессами каждый параллельный процесс уже занимает ядро — внутренний classifier, также запрашивающий несколько ядер, вызвал бы вложенный параллелизм, который ухудшает пропускную способность вместо улучшения. Тот же принцип применяется в обертке clf_hyper_fit из Части 7.
_load_if_exists=True_ означает, что запуск, прерванный по тайм-ауту, возобновится с последнего завершенного trial, если повторно вызвать его с теми же _study_name_ и _db_path_. В сочетании с архитектурой кэширования из Части 6 это означает, что полный повторный запуск pipeline после прерывания загрузит все кэшированные результаты preprocessing и возобновится ровно там, где остановилось Optuna study — без потери вычислений.
def optimize_trading_model( classifier, X: pd.DataFrame, y: pd.Series, events: pd.DataFrame, data_index: pd.DatetimeIndex, param_distributions: dict, n_trials: int = 100, timeout: int = 3600, n_splits: int = 5, pruner_type: str = "hyperband", metric: str = "neg_log_loss", study_name: str = None, db_path: str = None, random_state: int = 42, refit: bool = True, ): if pruner_type == "median": pruner = TradingModelPruner(y, sample_weight=events.loc[X.index, 'w']) elif pruner_type == "hyperband": pruner = HyperbandPruner(min_resource=1, max_resource=n_splits, reduction_factor=3) else: pruner = SuccessiveHalvingPruner() sampler = TPESampler(seed=random_state) storage_url = f"sqlite:///{db_path}.db?timeout=30" # 30s timeout for parallel workers try: study = optuna.create_study( direction="maximize", sampler=sampler, pruner=pruner, study_name=study_name, storage=storage_url, load_if_exists=True, # resume from last completed trial ) if hasattr(classifier, 'n_jobs'): classifier.set_params(n_jobs=1) # prevent oversubscription if hasattr(classifier, 'random_state'): classifier.set_params(random_state=random_state) def objective(trial): return optimize_trading_model_with_pruning( trial, X, y, events, data_index, classifier, param_distributions, n_splits, metric ) study.optimize( objective, n_trials=n_trials, timeout=timeout, callbacks=[print_best_trial, save_intermediate_results, check_for_overfitting], ) if refit: best_model = FinancialModelSuggester.apply_from_params( study.best_trial.params, classifier, events, data_index ) if hasattr(best_model, 'n_jobs'): best_model.set_params(n_jobs=-1) best_model.fit(X, y) study.best_estimator_ = best_model cv_results = optuna_to_cv_results(study) return study, cv_results except StorageInternalError as e: logger.error(f"Storage Error: {db_path} is locked or unreachable. {e}") except Exception as e: raise e
Три callbacks обеспечивают прозрачность процесса, не изменяя целевую функцию. print_best_trial логирует улучшения score в консоль. save_intermediate_results записывает каждое завершенное испытание на диск как JSON в каталог optuna_results/ — легковесный журнал аудита, аналогичный logging infrastructure из Части 7. check_for_overfitting выдает предупреждение, когда score_std превышает 0.3, отмечая модели, чувствительные к тому, какой рыночный режим покрывает фолд.
8. От Optuna Study к Scikit-Learn cv_results_
Существующий аналитический код в pipeline — включая функции анализа гиперпараметров в afml.cross_validation.hyper_fit_analysis — ожидает scikit-learn cv_results_ DataFrame. Условные пространства поиска создают разреженные строки: пространство поиска xgboost включает learning_rate и gamma, которых нет в испытаниях случайного леса. optuna_to_cv_results обрабатывает это, строя DataFrame с одной строкой на завершенное испытание и заполняя отсутствующие параметры NaN. Оценки по фолдам, сохраненные в trial.user_attrs["fold_scores"], разворачиваются в split0_test_score, split1_test_score и т. д., обеспечивая тот же анализ согласованности на уровне фолдов, что доступен в нативных CV results scikit-learn.
def optuna_to_cv_results(study): """Converts an Optuna study into a Scikit-Learn style cv_results_ DataFrame.""" rows = [] for trial in study.trials: if trial.state != optuna.trial.TrialState.COMPLETE: continue # exclude pruned trials from ranking res = { "mean_test_score": trial.value, "std_test_score": trial.user_attrs.get("score_std", 0), "mean_fit_time": (trial.datetime_complete - trial.datetime_start).total_seconds(), "params": trial.params, } for k, v in trial.params.items(): res[f"param_{k}"] = v fold_scores = trial.user_attrs.get("fold_scores", []) for i, score in enumerate(fold_scores): res[f"split{i}_test_score"] = score rows.append(res) return pd.DataFrame(rows)
9. Визуализация
После завершения study набор визуализаций Optuna дает диагностическое понимание процесса поиска. Все функции возвращают Plotly figures, доступные через optuna.visualization. Диаграммы ниже — репрезентативные примеры, созданные из синтетического financial ML study. Эти графики особенно ценны, потому что показывают информацию о ландшафте гиперпараметров, которую один cv_results_ DataFrame передать не может.
plot_optimization_history(study)
Показывает значение целевой функции по каждому испытанию (точки) и текущий лучший результат (линия). Разрыв между отдельными trial scores и текущим лучшим сужается по мере того, как TPE строит модель поверхности целевой функции. Плоский текущий лучший результат указывает на сходимость; продолжающееся улучшение лучшего результата говорит, что нужны дополнительные испытания. На практике TPE обычно сходится за 30–60 испытаний для пространства случайного леса с 4–5 параметрами.

Рис. 2 — История оптимизации
plot_intermediate_values(study)
Показывает скользящее среднее по фолдам для каждого испытания, а отсеченные испытания — короткими линиями, завершающимися раньше. Это основная диагностика для HyperbandPruner. Зеленые линии проходят все фолды и обычно группируются в верхней части диапазона score; красные линии отсекаются рано и заканчиваются на фолде 1. Если линии не отсекаются, reduction_factor может требовать настройки, либо поверхность целевой функции слишком плоская, чтобы оценки fold-1 были информативными.

Рис. 3 — Промежуточные значения (диагностика отсечения)
plot_param_importances(study)
Использует fANOVA для оценки вклада каждого гиперпараметра в дисперсию целевой функции. В финансовом ML параметры регуляризации (min_weight_fraction_leaf, ccp_alpha, max_depth) обычно доминируют над параметрами емкости (n_estimators), отражая, что переобучение под training regime — основная причина неудачи. Важность weight_scheme особенно информативна: высокая важность говорит, что выбор между uniqueness и return-attribution training дает измеримую разницу для конкретного инструмента и типа стратегии.

Рис. 4 — Важность параметров (fANOVA)
plot_parallel_coordinate(study)
Отображает каждое испытание как линию через параллельные оси, окрашенную по значению целевой функции. Высокоэффективные испытаний (зеленые) группируются в видимые полосы и показывают совместные взаимодействия параметров — например, что глубокое дерево улучшает производительность только в паре с сильным cost-complexity pruning через ccp_alpha. Это лучший график для определения того, какие комбинации параметров работают вместе, а не по отдельности.

Рис. 5 — Графики параллельных координат
plot_edf([study_optuna, study_random])
Строит эмпирическую CDF значений целевой функции по всем испытаниям для нескольких исследований. Study, чья EDF сдвинута вправо, стохастически доминирует другую по всему распределению испытаний. Это корректный способ показать, что Optuna превосходит RandomizedSearchCV при равном числе испытаний. Единственное сравнение best_score чувствительно к удачным выборкам и никогда не должно использоваться как единственный benchmark — EDF показывает полную картину.
vis.plot_edf требует список объектов optuna.Study. Поскольку RandomizedSearchCV создает cv_results_ DataFrame, а не study, нужен конвертер. randomized_search_to_study оборачивает каждую строку cv_results_ в завершенный optuna.Trial с использованием заглушек CategoricalDistribution — plot_edf читает только trial.value, поэтому тип распределения не важен. Вспомогательная функция plot_edf_comparison выполняет преобразование и переименование traces за один вызов.

Рис. 6 — Сравнение EDF: Optuna и RandomizedSearchCV
import optuna.visualization as vis from optuna.distributions import CategoricalDistribution fig = vis.plot_optimization_history(study) fig.show() fig = vis.plot_intermediate_values(study) # primary HyperbandPruner diagnostic fig.show() fig = vis.plot_param_importances(study) # weight_scheme importance is informative fig.show() fig = vis.plot_parallel_coordinate(study) fig.show() def randomized_search_to_study( gs, study_name: str = "randomized_search_baseline", ) -> optuna.Study: """ Convert a fitted RandomizedSearchCV into an Optuna Study for use with vis.plot_edf. vis.plot_edf requires optuna.Study objects and reads only trial.value. CategoricalDistribution stubs satisfy the API without affecting the plot. NaN mean_test_score rows (failed fits) are skipped. """ study = optuna.create_study(direction="maximize", study_name=study_name) cv_df = pd.DataFrame(gs.cv_results_) param_cols = [c for c in cv_df.columns if c.startswith("param_")] for _, row in cv_df.iterrows(): score = row["mean_test_score"] if pd.isna(score): continue params = {} for col in param_cols: val = row[col] # Skip NaN params from conditional search spaces if not (pd.isna(val) if not isinstance(val, str) else False): params[col.replace("param_", "")] = val distributions = { k: CategoricalDistribution([v]) for k, v in params.items() } trial = optuna.trial.create_trial( params=params, distributions=distributions, value=float(score), ) study.add_trial(trial) return study def plot_edf_comparison(study_optuna, gs_random): """ Compare an Optuna study against a fitted RandomizedSearchCV via EDF. Handles the conversion and trace renaming in one call. """ study_random = randomized_search_to_study(gs_random) fig = vis.plot_edf([study_optuna, study_random]) for trace in fig.data: if "random" in trace.name.lower(): trace.name = "RandomizedSearchCV" else: trace.name = f"Optuna ({study_optuna.study_name})" fig.update_layout( title="EDF Comparison — Optuna (TPE + Hyperband) vs RandomizedSearchCV" ) return fig # Usage: # study, cv_results = optimize_trading_model(...) # gs = RandomizedSearchCV(...); gs.fit(X, y, sample_weight=events['w']) # fig = plot_edf_comparison(study, gs) # fig.show()
10. Практические соображения
Зафиксируйте random seed в базовом estimator. Без него различия производительности между испытаниями отражают случайность, а не качество гиперпараметров. optimize_trading_model задает random_state у classifier до начала study. Это особенно важно для сравнения испытаний, которые отличаются только weight_scheme — разница должна объясняться схемой взвешивания, а не стохастичностью случайного леса.
Используйте информированные границы пространства поиска. Диапазоны вроде n_estimators в [1, 10000] тратят ранние испытания на экстремальные значения. Фабрика get_search_space предоставляет curated distributions как стартовую точку. Расширяйте диапазон только если plot_contour показывает, что лучшая область находится на границе текущего диапазона.
Используйте логарифмическую шкалу для мультипликативных параметров. learning_rate and ccp_alpha охватывают порядки величины. FinancialModelSuggester обрабатывает это автоматически, проверяя dist.dist.name для распределений loguniform и reciprocal и передавая log=True в trial.suggest_float.
Разрешите хотя бы один льготный фолд перед отсечением. Фолды финансовой CV покрывают разные рыночные режимы. TradingModelPruner применяет n_warmup_steps=2 до применения variance-based rules. HyperbandPruner консервативный bracket структурно дает ту же защиту — как минимум одно испытание всегда проходит до завершения в каждом bracket.
Интерпретируйте важность weight_scheme осторожно. Если plot_param_importances показывает weight_scheme как самый важный параметр, это не обязательно означает, что схема важнее всего для качества модели — это может означать, что остальные гиперпараметры уже хорошо ограничены prior knowledge и пространство поиска нуждается в уточнении.
Заключение
Запуск optimize_trading_model дает воспроизводимое Optuna study и три немедленных, проверяемых артефакта: сам Study (сохраненный в SQLite, возобновляемый через load_if_exists=True и совместимый с параллельными процессами); переобученный best_estimator_ (_WeightedEstimator оборачивает настроенную базовую модель, готовую к прогнозированию); и совместимый со scikit‑learn cv_results_ DataFrame с оценками по фолдам и метаданными стабильности фолдов, подходящий для тех же последующих диагностик, которые используются во всем pipeline. Практические преимущества закрывают финансовые узкие места, указанные во введении: раннее отсечение (HyperbandPruner или, для больших studies с откалиброванными базовыми порогами, TradingModelPruner) существенно снижает общий объем вычислений, завершая безнадежные trials после одного или нескольких фолдов; постоянное SQLite storage и архитектура кэширования из Части 6 позволяют долгим экспериментам возобновляться без повторения дорогого preprocessing; а разделенная обработка весов обучения и оценки гарантирует, что экономическая значимость учитывается и в fit, и в оценке качества.
Ключевые проектные выводы, которые можно применить сразу:
- Используйте PurgedKFold как единственный inner CV для triple‑barrier labels и всегда сообщайте fold‑level scores для отсечения — CPCV является инструментом backtesting и не дает скалярного выхода, подходящего для ранжирования комбинаций гиперпараметров.
- Предпочитайте HyperbandPruner как default для малых и средних studies (менее ~30 trials) или первых studies на неизвестных инструментах; используйте TradingModelPruner когда у вас большой бюджет испытаний (100+) и откалиброванный базовый порог.
- Рассматривайте схему весов обучения (unweighted, uniqueness, return) как гиперпараметр и всегда используйте веса return‑attribution (events['w']) для scoring, следуя соглашению dual‑weight из Части 5.
- Фиксируйте random seeds, используйте информированные границы поиска (логарифмическую шкалу для мультипликативных параметров, таких как ccp_alpha и learning_rate), и разрешайте льготное окно перед отсечением, чтобы учитывать неоднородность режимов между фолдами.
Наконец, соблюдайте границу: направленный поиск Optuna — правильный инструмент для статистической HPO на размеченных данных; он надежно находит оптимум, потому что целевая функция (кросс‑валидированный log‑loss на triple‑barrier labels) является корректной мерой обобщения. Это не безопасная замена оптимизации стратегии на историческом P&L, где та же надежность делает его более опасным, чем случайный поиск, а не менее. Часть 8.2 продемонстрирует интеграцию этого HPO-контура в production pipeline через обертку clf_hyper_fit, подключение к существующей инфраструктуре кэширования и путь экспорта ONNX для развертывания в MetaTrader 5.
Прикрепленные файлы
В таблице ниже описан каждый файл, прикрепленный к этой статье, и его роль в системе HPO. Файлы из production module и cross-validation module общие с Частью 7; файлы с префиксом optuna_hyper_fit представлены здесь.
| Файл | Модуль | Роль в этой статье | Ключевые зависимости | |
|---|---|---|---|---|
| 1. | optuna_hyper_fit.py | afml.cross_validation | Основной модуль HPO. Содержит FinancialModelSuggester, optimize_trading_model_with_pruning, TradingModelPruner, optimize_trading_model, optuna_to_cv_results, а также утилиты сравнения EDF, введенные в этой статье. | cross_validation.py (PurgedKFold), model_development.py (_WeightedEstimator), optuna ≥ 3.0, scikit-learn ≥ 1.3, loguru |
| 2. | model_development.py | afml.production | Предоставляет _WeightedEstimator — совместимую со sklearn обертку, которая вычисляет и применяет веса обучающей выборки (uniqueness или return-attribution) внутренне, чтобы цикл целевой функции не обрабатывал их явно. Также предоставляет TickDataLoader и более широкую инфраструктуру pipeline из Части 7. | utils.py (date_conversion), пакет MetaTrader5 Python, scikit-learn, pandas, numpy |
| 3. | utils.py | afml.production | Служебные функции, общие для production module, включая вспомогательные функции преобразования дат, используемые TickDataLoader и процедуры валидации данных, вызываемые в точках входа пайплайна. | pandas, numpy, пакет MetaTrader5 Python |
| 4. | cross_validation.py | afml.cross_validation | Предоставляет PurgedKFold — единственный разделитель внутренний CV-разделитель для финансового HPO. Вызывается напрямую внутри optimize_trading_model_with_pruning с t1 из events DataFrame и 1% embargo. Также предоставляет CombinatorialPurgedCV для внешнего backtesting loop, который отличается от HPO и не используется внутри функций этой статьи. | scikit-learn (BaseCrossValidator), pandas, numpy |
| 5. | hyper_fit_analysis.py | afml.cross_validation | Функции пост-анализa, использующие cv_results_ DataFrame, созданный optuna_to_cv_results. Включает графики согласованности на уровне фолдов, сводки взаимодействия параметров и функцию ml_cross_val_scores_all из Части 5, установившую соглашение dual-weight scoring, на котором строится эта статья. | cross_validation.py (PurgedKFold), pandas, numpy, scikit-learn, matplotlib |
Перевод с английского произведен MetaQuotes Ltd.
Оригинальная статья: https://www.mql5.com/en/articles/20117
Предупреждение: все права на данные материалы принадлежат MetaQuotes Ltd. Полная или частичная перепечатка запрещена.
Данная статья написана пользователем сайта и отражает его личную точку зрения. Компания MetaQuotes Ltd не несет ответственности за достоверность представленной информации, а также за возможные последствия использования описанных решений, стратегий или рекомендаций.
Автоматизация торговых стратегий в MQL5 (Часть 29): Создание системы торговли по гармоническому паттерну "Гартли" на основе Price Action
Разработка инструментария для анализа Price Action (Часть 45): Создание динамической панели для анализа уровней в MQL5
Низкочастотные количественные стратегии в MetaTrader 5: (Часть 2) Бэктестинг lead/lag-анализа в SQL и MetaTrader 5
От "лучшего прохода" к устойчивым решениям: исследование поверхности оптимизации в MetaTrader 5
- Бесплатные приложения для трейдинга
- 8 000+ сигналов для копирования
- Экономические новости для анализа финансовых рынков
Вы принимаете политику сайта и условия использования