← 返回文章列表
March 11, 2026
5 分鐘閱讀

座標下降法 vs 貝葉斯最佳化:哪種方法能找到更好的參數

座標下降法 vs 貝葉斯最佳化:哪種方法能找到更好的參數
#演算法交易
#回測
#最佳化
#Optuna
#TPE
#貝葉斯最佳化
#座標下降法
#超參數

這是"回測無幻覺"系列的第五篇文章。在之前的文章中,我們討論了虧損與盈利的不對稱性Monte Carlo自舉法資金費率的影響Parquet快取加速回測。現在讓我們討論尋找最優策略參數的過程——這是一個直覺最常失效的任務。

你有一個包含12個參數的策略。每個參數有約9個取值。你想找到在限定回撤下最大化PnL的組合。你怎麼做?

如果你的答案是"遍歷所有組合"——你有一個問題。如果你的答案是"一次修改一個參數"——你有另一個問題。本文討論每種方法背後隱藏的問題以及如何解決它們。

為什麼窮舉搜尋不可行

維度災難:搜尋空間的指數級增長

維度災難

窮舉搜尋(網格搜尋)測試每個參數的每個值的所有組合。對於2個參數各9個值,即 92=819^2 = 81 次執行——完全可行。對於3個:93=7299^3 = 729——還可以接受。

但對於有12個參數的實際策略:

Ngrid=912=282,429,536,481N_{grid} = 9^{12} = 282{,}429{,}536{,}481

兩千八百二十四億次執行。即使單次回測只需1秒(這已經是樂觀估計),窮舉搜尋將需要:

T=282×1093600×24×3658,950 年T = \frac{282 \times 10^{9}}{3600 \times 24 \times 365} \approx 8{,}950 \text{ 年}

這是指數增長:每增加一個新參數,搜尋空間就乘以9。增加第13個參數——從9000年變成80000年。

import math

def grid_search_cost(n_params: int, values_per_param: int, seconds_per_trial: float) -> dict:
    """估算穷举搜索的成本。"""
    total_trials = values_per_param ** n_params
    total_seconds = total_trials * seconds_per_trial
    return {
        "total_trials": total_trials,
        "total_hours": total_seconds / 3600,
        "total_years": total_seconds / (3600 * 24 * 365),
    }

cost = grid_search_cost(12, 9, 1.0)
print(f"Trials: {cost['total_trials']:,.0f}")      # 282,429,536,481
print(f"Years:  {cost['total_years']:,.0f}")        # 8,950

即使有預計算

在關於Parquet快取的文章中,我們展示了預計算時間框架和指標如何將單次回測加速到約1秒。但即使每次執行0.1秒,12個參數的窮舉搜尋仍需895年。預計算有所幫助,但無法解決指數增長的根本問題。

我們需要比窮舉搜尋更智慧地探索參數空間的方法。

座標下降法和OAT:快速但盲目

Parameter space exploration: OAT vs Bayesian optimization

同一思想的兩種變體

有兩種相關方法——都是一次最佳化一個參數,但在遍歷次數上有所不同:

OAT(One-at-a-Time)掃描 ——對所有參數進行單次遍歷。遍歷第一個參數的值,固定最佳值,轉到第二個——依此類推。只做一次。快速且廉價。

座標下降法(Coordinate Descent) ——多次遍歷。最佳化完最後一個參數後,回到第一個檢查最優值是否改變(因為上下文變了——其他參數值已經不同)。重複輪次直到收斂。更昂貴,但更精確——每輪都可以細化結果。

在實踐中,回測更常使用OAT:對12個參數進行單次遍歷——96次執行。3-5輪的座標下降——300-500次執行,已經與Optuna相當,但沒有其優勢。

對於12個參數,每個約8個取值:

NOAT=K×N=12×8=96 次运行N_{OAT} = K \times N = 12 \times 8 = 96 \text{ 次运行}

與網格搜尋的 282×109282 \times 10^9 相比。OAT是線性的:O(KN)O(K \cdot N) 而非 O(NK)O(N^K)。這既是它的主要優勢,也是它的主要問題。

def oat_sweep(
    param_grid: dict[str, list],
    run_backtest_fn,
    initial_params: dict,
    metric: str = "effective_score",
) -> dict:
    """
    OAT扫描:单次遍历,一次优化一个参数。

    param_grid: {"htf_entry_sell": [0.0, 0.005, ..., 0.05], ...}
    initial_params: 所有参数的初始值
    metric: 优化指标(推荐effective_score——
            按活跃时间计算的PnL,年化外推)
    """
    best_params = initial_params.copy()
    best_score = run_backtest_fn(**best_params)[metric]

    for param_name, values in param_grid.items():
        param_best_val = best_params[param_name]
        param_best_score = best_score

        for val in values:
            candidate = best_params.copy()
            candidate[param_name] = val
            result = run_backtest_fn(**candidate)
            score = result[metric]

            if score > param_best_score:
                param_best_score = score
                param_best_val = val

        best_params[param_name] = param_best_val
        best_score = param_best_score
        print(f"{param_name}: best={param_best_val}, score={param_best_score:.4f}")

    return best_params

最佳化應該選擇哪個指標? 建議不要使用原始PnL或PnL@MaxLev,而是使用effective score——按活躍時間計算的PnL並年化外推。該指標考慮了持倉時間,允許正確比較不同交易頻率的策略。

盲區:參數互動作用

OAT假設每個參數的影響是可加的——即一個參數的最優值不依賴於其他參數的值。這個假設對某些參數成立,但對耦合參數則不成立。

可加參數 vs 耦合參數

在最佳化之前,對參數進行分類是有用的:

可加(獨立)參數 ——一個的最優值不依賴於另一個。可以廉價地逐個最佳化:

  • htf_entry_sellhtf_entry_buy ——同一時間框架上不同方向(賣/買)的入場閾值。賣出閾值過濾做空訊號,買入閾值過濾做多訊號。它們作用於不重疊的交易子集。
  • tp_targetbe_trigger ——止盈和保本,如果它們不產生衝突的退出條件。

耦合(互動)參數 ——一個的最優值依賴於另一個。需要聯合最佳化:

  • htf_entry_sellmtf_entry_sell ——同一方向(賣出)在不同時間框架上的閾值。HTF決定哪些訊號到達MTF,MTF閾值決定過濾效果。當MTF變化時,HTF最優值會偏移。
  • ltf_entry_sellmtf_entry_sellhtf_entry_sell ——一個方向的整個閾值鏈。
  • partial_fractp_target ——部分平倉大小取決於TP水平。

實用方法: 首先通過OAT廉價最佳化可加參數。然後通過Optuna最佳化耦合組。這減少了預算:不是在Optuna中放入12個參數,而是隻放入6-8個耦合參數,其餘已經固定。

示例:OAT如何遺漏互動作用

考慮兩個耦合閾值:

  • htf_entry_sell ——高時間框架上的閾值(賣出方向)
  • mtf_entry_sell ——中時間框架上的閾值(賣出方向)

OAT固定 mtf_entry_sell = 0.01(初始值)並遍歷 htf_entry_sell。找到最佳值:htf_entry_sell = 0.02。固定它並轉到下一個參數——不再返回。

這是OAT遺漏的:

htf_entry_sell mtf_entry_sell PnL
0.02 0.01 +42%
0.02 0.02 +38%
0.03 0.02 +51%
0.03 0.01 +35%

組合 (0.03, 0.02) 產生PnL +51%,但OAT永遠不會考慮它,因為在固定 mtf_entry_sell = 0.01 時,值 htf_entry_sell = 0.03 只產生+35%。OAT"卡在"了局部最優 (0.02, 0.01),看不到全域性最優 (0.03, 0.02)

這是一個經典問題:如果目標函數的景觀包含對角山脊(當一個參數的最優值隨另一個參數變化而偏移時),OAT會遺漏它們。

問題的形式化

f(θ1,θ2,,θK)f(\theta_1, \theta_2, \ldots, \theta_K) 為目標函數(PnL)。OAT找到的點滿足:

fθi=0i\frac{\partial f}{\partial \theta_i} = 0 \quad \forall i

但這是全域性最優的必要條件,而非充分條件。如果Hessian矩陣 Hij=2fθiθjH_{ij} = \frac{\partial^2 f}{\partial \theta_i \partial \theta_j} 有顯著的非對角元素——OAT不考慮交叉導數 2fθiθj\frac{\partial^2 f}{\partial \theta_i \partial \theta_j}iji \neq j 時)。

對於耦合參數(同一方向跨多個時間框架的閾值)——互動作用是常態而非例外。高時間框架的入場閾值決定哪些訊號到達中時間框架,中時間框架的閾值決定低時間框架的過濾效果。對於可加參數(不同方向、獨立過濾器),交叉導數接近零——OAT表現良好。

貝葉斯最佳化:智慧搜尋

貝葉斯最佳化:目標函數的代理模型

基本思想

貝葉斯最佳化不是盲目列舉或貪心搜尋,而是構建目標函數的代理模型,在每一步選擇期望改進最大的點。

演算法:

  1. 選擇幾個隨機點,計算目標函數
  2. 構建代理模型(根據觀察到的點近似 f(θ)f(\theta)
  3. 找到期望改進最大的點(採集函數)
  4. 在該點計算目標函數
  5. 更新代理模型
  6. 重複步驟3-5

與OAT的關鍵區別:貝葉斯最佳化同時考慮所有參數,可以探索參數空間中的對角山脊。

TPE(基於樹結構的Parzen估計器)

TPE取樣器:建模優良和不良參數分佈

TPE是Optuna的預設取樣器。TPE不直接建模 f(θ)f(\theta),而是建模兩個分佈:

  • l(θ)l(\theta) ——目標函數優於閾值 yy^* 的參數分佈
  • g(θ)g(\theta) ——目標函數劣於閾值 yy^* 的參數分佈

TPE的採集函數——比率:

EI(θ)l(θ)g(θ)\text{EI}(\theta) \propto \frac{l(\theta)}{g(\theta)}

TPE選擇 l(θ)l(\theta) 大(參數類似於"好的")且 g(θ)g(\theta) 小(參數不類似於"差的")的點。

為什麼TPE適合回測:

  • 處理參數間的條件依賴關係
  • 不要求目標函數連續
  • 在中等預算下高效(100-1000次迭代)
  • 支援類別型和離散型參數

高斯過程(GP)

TPE的替代方案——高斯過程。GP將 f(θ)f(\theta) 建模為多元正態過程,不僅提供值的預測,還提供每個點的不確定性

f(θ)GP(m(θ),  k(θ,θ))f(\theta) \sim \mathcal{GP}\bigl(m(\theta),\; k(\theta, \theta')\bigr)

其中 m(θ)m(\theta) 是均值,k(θ,θ)k(\theta, \theta') 是協方差函數(核函數)。

GP在以下情況下表現良好:

  • 參數較少(最多10-15個)
  • 目標函數平滑
  • 每次執行成本高(分鐘、小時級別)

對於使用預計算Parquet快取的回測,單次執行約1秒,通常更推薦TPE:它構建模型更快,在500+次迭代上擴充性更好。

與Optuna的實際整合

Optuna最佳化框架:迭代參數搜尋

完整工作示例

import optuna
from optuna.samplers import TPESampler
import numpy as np


def run_backtest(htf_pre, mtf_pre, ltf_pre, **params) -> dict:
    """
    使用给定参数运行回测。
    返回包含指标的dict:pnl, max_dd, n_trades, trading_time, sharpe。
    使用预计算的Parquet缓存——每次运行约1秒。
    """
    pass


def objective(trial: optuna.Trial) -> float:
    """Optuna的目标函数。"""
    params = {
        "htf_entry_sell": trial.suggest_float("htf_entry_sell", 0.0, 0.05, step=0.005),
        "htf_entry_buy":  trial.suggest_float("htf_entry_buy",  0.0, 0.05, step=0.005),

        "mtf_entry_sell": trial.suggest_float("mtf_entry_sell", 0.0, 0.05, step=0.005),
        "mtf_entry_buy":  trial.suggest_float("mtf_entry_buy",  0.0, 0.05, step=0.005),

        "ltf_entry_sell": trial.suggest_float("ltf_entry_sell", 0.0, 0.05, step=0.005),
        "ltf_entry_buy":  trial.suggest_float("ltf_entry_buy",  0.0, 0.05, step=0.005),

        "htf_exit_sell":  trial.suggest_float("htf_exit_sell",  0.0, 0.03, step=0.005),
        "htf_exit_buy":   trial.suggest_float("htf_exit_buy",   0.0, 0.03, step=0.005),
        "mtf_exit_sell":  trial.suggest_float("mtf_exit_sell",  0.0, 0.03, step=0.005),
        "mtf_exit_buy":   trial.suggest_float("mtf_exit_buy",   0.0, 0.03, step=0.005),

        "min_hold_bars":  trial.suggest_int("min_hold_bars", 1, 20),
        "trail_pct":      trial.suggest_float("trail_pct", 0.001, 0.02, step=0.001),
    }

    result = run_backtest(htf_pre, mtf_pre, ltf_pre, **params)

    return -result["pnl_at_max_lev"]


study = optuna.create_study(
    sampler=TPESampler(seed=42),
    study_name="strategy_optimization",
    direction="minimize",
)

study.optimize(objective, n_trials=500, show_progress_bar=True)

print(f"Best PnL: {-study.best_value:.2f}%")
print(f"Best params: {study.best_params}")
print(f"Total trials: {len(study.trials)}")

以每次回測約1秒的速度(使用預計算快取):

T500=500×18 分钟T_{500} = 500 \times 1\text{秒} \approx 8 \text{ 分钟}

8分鐘對比窮舉搜尋的8950年。而且TPE在500次迭代中能找到OAT在96次中遺漏的組合,因為它同時探索參數空間,而不是逐軸搜尋。

儲存和恢復研究

import optuna

study = optuna.create_study(
    storage="sqlite:///optuna_study.db",
    study_name="strategy_v2",
    sampler=TPESampler(seed=42),
    direction="minimize",
    load_if_exists=True,  # 如果研究已存在则继续
)

study.optimize(objective, n_trials=300)


study.optimize(objective, n_trials=200)

新增約束

並非所有參數組合都有效。例如,退出閾值不應大於入場閾值:

def objective_with_constraints(trial: optuna.Trial) -> float:
    htf_entry = trial.suggest_float("htf_entry_sell", 0.0, 0.05, step=0.005)
    htf_exit  = trial.suggest_float("htf_exit_sell",  0.0, 0.03, step=0.005)

    if htf_exit > htf_entry:
        raise optuna.TrialPruned()

    result = run_backtest(htf_pre, mtf_pre, ltf_pre, **params)
    return -result["pnl_at_max_lev"]

取樣器對比

Sampler convergence comparison over iterations

Optuna支援多種取樣器。每種都有其優勢。

TPESampler(預設)

sampler = optuna.samplers.TPESampler(
    n_startup_trials=20,  # 开始建模前的随机试验次数
    seed=42,
)
  • 原理: 基於樹結構的Parzen估計器
  • 優勢: 適合混合參數型別,可擴充到1000+次迭代
  • 劣勢: 在強參數互動作用下可能效率較低
  • 使用場景: 預設選擇,除非有理由選擇其他

CmaEsSampler

sampler = optuna.samplers.CmaEsSampler(seed=42)
  • 原理: 協方差矩陣自適應進化策略——一種自適應協方差矩陣的進化演算法
  • 優勢: 出色地發現連續參數間的互動作用,考慮相關性
  • 劣勢: 不支援類別型參數,初始化需要更多迭代
  • 使用場景: 如果所有參數都是連續的且你懷疑存在強互動作用

GPSampler

sampler = optuna.samplers.GPSampler(seed=42)
  • 原理: 帶採集函數的高斯過程
  • 優勢: 最佳取樣效率(更少的迭代獲得好結果),提供不確定性估計
  • 劣勢: 迭代次數的 O(n3)O(n^3) 複雜度——當 n>200n > 200 時速度慢
  • 使用場景: 如果單次回測昂貴(分鐘級)且預算限於100-200次迭代

RandomSampler(基準線)

sampler = optuna.samplers.RandomSampler(seed=42)
  • 原理: 均勻隨機取樣
  • 優勢: 不會陷入區域性最優,完整空間覆蓋
  • 劣勢: 不利用之前的結果
  • 使用場景: 作為比較的基準線,或用於探索性分析

QMCSampler

sampler = optuna.samplers.QMCSampler(seed=42)
  • 原理: 準蒙特卡洛(Sobol/Halton序列)——比隨機取樣器更均勻地填充空間
  • 優勢: 比RandomSampler更好的空間覆蓋,可復現性
  • 劣勢: 不適應結果
  • 使用場景: 在切換到TPE之前的前50-100次迭代

總結表

取樣器 型別 互動作用 類別型 最佳預算
TPE 貝葉斯 部分 100-1000
CmaEs 進化 200-2000
GP 貝葉斯 有限 50-200
Random 隨機 任意(基準線)
QMC 準隨機 50-500

實際基準測試

import optuna
import time

def benchmark_sampler(sampler, n_trials=300):
    """在同一任务上比较采样器。"""
    study = optuna.create_study(sampler=sampler, direction="minimize")

    start = time.time()
    study.optimize(objective, n_trials=n_trials, show_progress_bar=False)
    elapsed = time.time() - start

    return {
        "best_value": -study.best_value,
        "elapsed_sec": elapsed,
        "best_trial": study.best_trial.number,
    }

samplers = {
    "TPE":    optuna.samplers.TPESampler(seed=42),
    "CmaEs":  optuna.samplers.CmaEsSampler(seed=42),
    "GP":     optuna.samplers.GPSampler(seed=42),
    "Random": optuna.samplers.RandomSampler(seed=42),
    "QMC":    optuna.samplers.QMCSampler(seed=42),
}

for name, sampler in samplers.items():
    result = benchmark_sampler(sampler, n_trials=300)
    print(f"{name:8s}: best PnL={result['best_value']:.2f}%, "
          f"found at trial #{result['best_trial']}, "
          f"time={result['elapsed_sec']:.1f}s")

12參數策略的典型結果:

取樣器 最佳PnL 發現於第幾次迭代 取樣器開銷
TPE ~51% ~180
CmaEs ~49% ~250
GP ~48% ~90 n>200n > 200 時高
Random ~42% ~270 最小
QMC ~43% ~200 最小

TPE和CmaEs在最終PnL上始終比隨機搜尋高出15-20%。GP能更早找到好的結果,但在迭代次數多時會遇到計算瓶頸。

多目標最佳化:PnL vs MaxDD

帕累托前沿:PnL與最大回撤之間的權衡

為什麼單一標準不夠

在沒有回撤限制的情況下最大化PnL是一條通往災難的路。由於虧損與盈利的不對稱性,PnL +80%且MaxDD -30%的策略比PnL +50%且MaxDD -5%的策略風險大得多。

最佳化問題實際上是多目標的

maxθ  PnL(θ)使得MaxDD(θ)min\max_{\theta} \; \text{PnL}(\theta) \quad \text{使得} \quad \text{MaxDD}(\theta) \to \min

這些目標相互衝突:激進的參數同時增加PnL和回撤。解決方案不是單一的點,而是帕累托前沿:一組解,在其中無法改善一個指標而不惡化另一個。

Optuna中的NSGA-II / NSGA-III

import optuna

def multi_objective(trial: optuna.Trial) -> tuple[float, float]:
    """多目标函数:(PnL, MaxDD)。"""
    params = {
        "htf_entry_sell": trial.suggest_float("htf_entry_sell", 0.0, 0.05, step=0.005),
        "htf_entry_buy":  trial.suggest_float("htf_entry_buy",  0.0, 0.05, step=0.005),
        "mtf_entry_sell": trial.suggest_float("mtf_entry_sell", 0.0, 0.05, step=0.005),
        "mtf_entry_buy":  trial.suggest_float("mtf_entry_buy",  0.0, 0.05, step=0.005),
        "ltf_entry_sell": trial.suggest_float("ltf_entry_sell", 0.0, 0.05, step=0.005),
        "ltf_entry_buy":  trial.suggest_float("ltf_entry_buy",  0.0, 0.05, step=0.005),
        "htf_exit_sell":  trial.suggest_float("htf_exit_sell",  0.0, 0.03, step=0.005),
        "htf_exit_buy":   trial.suggest_float("htf_exit_buy",   0.0, 0.03, step=0.005),
        "mtf_exit_sell":  trial.suggest_float("mtf_exit_sell",  0.0, 0.03, step=0.005),
        "mtf_exit_buy":   trial.suggest_float("mtf_exit_buy",   0.0, 0.03, step=0.005),
        "min_hold_bars":  trial.suggest_int("min_hold_bars", 1, 20),
        "trail_pct":      trial.suggest_float("trail_pct", 0.001, 0.02, step=0.001),
    }

    result = run_backtest(htf_pre, mtf_pre, ltf_pre, **params)

    pnl = result["pnl"]          # 最大化
    max_dd = result["max_dd"]    # 最小化(已经是负数)

    return pnl, max_dd  # Optuna:两个方向在create_study中设置


study = optuna.create_study(
    directions=["maximize", "minimize"],
    sampler=optuna.samplers.NSGAIIISampler(seed=42),
    study_name="multi_objective_strategy",
)

study.optimize(multi_objective, n_trials=500)

pareto_trials = study.best_trials
print(f"Pareto front: {len(pareto_trials)} solutions")

for t in pareto_trials[:5]:
    print(f"  PnL={t.values[0]:.2f}%, MaxDD={t.values[1]:.2f}%")

在帕累托前沿上選擇一個點

帕累托前沿給出多個解。如何選擇一個?

def select_from_pareto(
    pareto_trials: list,
    max_dd_limit: float = -5.0,
    min_pnl: float = 20.0,
) -> list:
    """
    按约束过滤帕累托前沿。

    max_dd_limit: 最大可接受回撤(例如 -5%)
    min_pnl: 最小可接受PnL(%)
    """
    filtered = []
    for trial in pareto_trials:
        pnl, max_dd = trial.values
        if max_dd >= max_dd_limit and pnl >= min_pnl:
            max_lev = min(50 / abs(max_dd), 100) if max_dd != 0 else 100
            pnl_at_max_lev = pnl * max_lev
            filtered.append({
                "trial": trial,
                "pnl": pnl,
                "max_dd": max_dd,
                "max_lev": max_lev,
                "pnl_at_max_lev": pnl_at_max_lev,
            })

    filtered.sort(key=lambda x: x["pnl_at_max_lev"], reverse=True)
    return filtered

注意:計算最大槓桿下的PnL時,必須考慮資金費率,否則理論上高槓杆在實際市場中會變成虧損。此外,最終PnL是單點估計,要評估結果的穩定性需要Monte Carlo自舉法

示例:帕累托前沿上的三種策略

策略 PnL MaxDD MaxLev PnL@MaxLev 交易時間
策略A ~55% ~0.9% ~55x ~3025% ~15%
策略B ~25% ~0.75% ~66x ~1650% ~5%
策略C ~300% ~17% ~3x ~900% ~45%

PnL +300%的策略C看起來令人印象深刻,但由於高回撤,其PnL@MaxLev最低。策略A在槓桿淨收益方面領先,但考慮按活躍時間計算的PnL,策略B可能更可取——95%的空閒時間可以用於其他策略。

等高線圖和參數重要性

等高線圖:視覺化參數互動和平臺區域

景觀視覺化

最佳化之後是視覺化。Optuna提供內建工具:

import optuna.visualization as vis

fig_contour = vis.plot_contour(
    study,
    params=["htf_entry_sell", "mtf_entry_sell"],
)
fig_contour.show()

fig_importance = vis.plot_param_importances(study)
fig_importance.show()

fig_history = vis.plot_optimization_history(study)
fig_history.show()

fig_parallel = vis.plot_parallel_coordinate(
    study,
    params=["htf_entry_sell", "mtf_entry_sell", "ltf_entry_sell"],
)
fig_parallel.show()

fig_slice = vis.plot_slice(study)
fig_slice.show()

等高線圖:解讀互動作用

等高線圖構建目標函數對一對參數的二維截面。如果等高線平行於某一軸——參數之間沒有互動作用,OAT會找到相同的最優值。如果等高線是對角的——存在互動作用,OAT會遺漏。

key_params = ["htf_entry_sell", "mtf_entry_sell", "ltf_entry_sell",
              "htf_entry_buy",  "mtf_entry_buy",  "ltf_entry_buy"]

for i, p1 in enumerate(key_params):
    for p2 in key_params[i+1:]:
        fig = vis.plot_contour(study, params=[p1, p2])
        fig.write_image(f"contour_{p1}_vs_{p2}.png")

如果等高線圖顯示平臺——目標函數變化很小的區域——這是一個好跡象。平臺意味著結果對參數的小偏差是穩健的。關於平臺分析及其與過擬合的關係的更多內容——在即將發表的文章平臺分析中。

參數重要性

importance = optuna.importance.get_param_importances(study)
for param, imp in importance.items():
    print(f"{param:20s}: {imp:.4f}")

典型輸出:

htf_entry_sell      : 0.2841
mtf_entry_sell      : 0.2103
ltf_entry_sell      : 0.1567
trail_pct           : 0.1204
htf_entry_buy       : 0.0892
...

重要性 < 0.01的參數可以固定在預設值——這降低了問題的維度並加速最佳化。但要小心:低重要性也可能意味著該參數僅在與其他參數互動時才重要。通過等高線圖驗證。

預計算快取:為什麼每次回測1秒改變一切

預計算Parquet快取:將回測從數小時加速到數秒

單次回測的速度決定了你能負擔得起哪種最佳化方法。

回測時間 96次OAT 500次TPE 2000次CmaEs
60秒 1.6小時 8.3小時 33小時
10秒 16分鐘 83分鐘 5.5小時
1秒 1.5分鐘 8分鐘 33分鐘
0.1秒 10秒 50秒 3.3分鐘

每次回測60秒時,500次TPE迭代需要8小時。已經可以忍受,但迭代(修改目標函數、重新啟動)成本高。1秒時——8分鐘,一天可以執行數十個實驗。

這正是為什麼預計算到Parquet快取不僅僅是速度最佳化,而是擴充了可用方法的空間。沒有快取,你只能用OAT或100次GP迭代。有了快取——你可以負擔得起2000次CmaEs迭代或完整的多目標NSGA-III。

import pyarrow.parquet as pq
import time

t0 = time.time()
htf_pre = pq.read_table("cache/htf_indicators.parquet").to_pandas()
mtf_pre = pq.read_table("cache/mtf_indicators.parquet").to_pandas()
ltf_pre = pq.read_table("cache/ltf_indicators.parquet").to_pandas()
print(f"Cache loaded in {time.time() - t0:.2f}s")  # ~0.3s

t1 = time.time()
result = run_backtest(htf_pre, mtf_pre, ltf_pre, htf_entry_sell=0.02, ...)
print(f"Backtest in {time.time() - t1:.2f}s")  # ~1.0s

實用建議

混合方法:結合OAT與貝葉斯最佳化

何時使用OAT

OAT在以下情況下是合理的:

  1. 探索性分析。 你剛開始探索一個策略,想了解哪些參數對結果有影響。96次執行1.5分鐘——一個出色的起點。

  2. 可加參數。 對於在不重疊交易子集上執行的參數(賣/買方向、不同工具),OAT能更快給出正確結果。

  3. 非常昂貴的回測。 如果單次執行需要10+分鐘且無法加速,96次OAT執行(16小時)優於500次TPE迭代(3.5天)。

何時使用Optuna

Optuna在大多數情況下更可取:

  1. 超過3個參數。 互動作用幾乎是必然的——OAT會遺漏最優值。

  2. 多時間框架策略。 不同時間框架上的閾值幾乎總是相互關聯的。

  3. 最終最佳化。 當策略通過了Monte Carlo自舉法且你對其穩健性有信心——Optuna會找到最佳參數。

  4. 多目標問題。 PnL vs MaxDD vs 交易時間——OAT原則上無法解決此問題。

混合方法:OAT用於可加參數 + Optuna用於耦合參數

不必在OAT和Optuna之間選擇——最好結合使用:

  1. 分類參數。 分為可加(獨立)和耦合(互動)。12個分離參數的示例:

    • 可加: htf_entry_sell <-> htf_entry_buymtf_entry_sell <-> mtf_entry_buyltf_entry_sell <-> ltf_entry_buy(賣/買——不同方向,作用於不重疊的交易)
    • 耦合組sell: htf_entry_sellmtf_entry_sellltf_entry_sell(過濾鏈:HTF -> MTF -> LTF用於賣出訊號)
    • 耦合組buy: htf_entry_buymtf_entry_buyltf_entry_buy
  2. OAT用於可加參數。 獨立最佳化賣出和買入組。如果賣出參數不影響買入交易——OAT在幾分鐘內給出正確結果。

  3. Optuna用於耦合參數。 在每個組內(sell:6個入場+出場參數)使用TPE。6個參數而非12個——預算減半。

sell_params = oat_sweep(sell_param_grid, run_backtest, initial_params)

def objective_sell(trial):
    params = sell_params.copy()
    params["htf_entry_sell"] = trial.suggest_float("htf_entry_sell", 0.0, 0.05, step=0.005)
    params["mtf_entry_sell"] = trial.suggest_float("mtf_entry_sell", 0.0, 0.05, step=0.005)
    params["ltf_entry_sell"] = trial.suggest_float("ltf_entry_sell", 0.0, 0.05, step=0.005)
    params["htf_exit_sell"] = trial.suggest_float("htf_exit_sell", 0.0, 0.02, step=0.001)
    params["mtf_exit_sell"] = trial.suggest_float("mtf_exit_sell", 0.0, 0.02, step=0.001)
    params["ltf_exit_sell"] = trial.suggest_float("ltf_exit_sell", 0.0, 0.02, step=0.001)
    return -run_backtest(**params)["effective_score"]

study = optuna.create_study(sampler=optuna.samplers.TPESampler())
study.optimize(objective_sell, n_trials=300)  # 6个参数 → 300次足够

完整最佳化流程

1. 预计算Parquet缓存(一次)
2. 分类参数:可加 vs 耦合
3. OAT用于可加参数(~50次运行,~1分钟)→ 固定
4. Optuna TPE用于耦合组(300次迭代 × 2组,~10分钟)
5. Optuna NSGA-III用于元参数(500次迭代,~8分钟)→ 帕累托前沿
6. 等高线图 → 可视化交互作用
7. Monte Carlo自举法用于最佳点 → 置信区间
8. Walk-Forward → 样本外验证

第8步——Walk-Forward最佳化——對於防止過擬合至關重要。更多內容請參見即將發表的文章Walk-Forward

最佳化陷阱

過擬合。 參數越多、最佳化越精確——將策略擬合到歷史資料的風險就越高。500次Optuna迭代處理12個參數會找到一個在訓練集上完美執行但在新資料上毫無用處的組合。

防護措施:

  • 將資料分為訓練/測試集(70/30)
  • 使用Monte Carlo自舉法評估穩定性
  • 通過Walk-Forward驗證
  • 優先選擇平臺上的解(更多內容在平臺分析中)

多重比較問題。 如果你測試500種組合,隨機找到"好"結果的機率會增加。Bonferroni校正或FDR(假髮現率)控制有所幫助,但更簡單的方法是樣本外驗證。

預算不足。 12個參數的TPE只用50次迭代太少了。前20次迭代是隨機的(啟動),只剩30次用於建模。最低預算:10×K=12010 \times K = 120 次迭代(12個參數),推薦:3050×K30\text{--}50 \times K

Freqtrade:生產框架中的實現

Freqtrade:整合Optuna的自動化交易框架

Freqtrade——流行的演算法交易框架之一——通過Hyperopt模組在底層使用Optuna。其經驗驗證了我們的建議:

  • 取樣器: TPE(預設)、GP、CmaEs、NSGA-II、QMC——都可通過配置使用
  • 損失函數: 12個內建損失函數,包括ShortTradeDurHyperOptLoss、SharpeHyperOptLoss、MaxDrawDownHyperOptLoss
  • 多目標: 支援NSGA-II和NSGA-III同時最佳化多個指標
  • 自定義取樣器: 可插入任何Optuna相容的取樣器

Freqtrade生態系統的關鍵經驗:內建損失函數覆蓋了典型場景,但對於嚴肅的最佳化,你需要一個自定義目標函數,考慮你的策略特性——活躍時間、資金費用、用於精確成交模擬的自適應下鑽。

結論

完整最佳化流程:從資料到驗證後的參數

座標下降法(OAT)是一種快速且直覺上易於理解的方法。對於12個參數,它只需96次執行,一分半鐘即可完成。但它對參數互動作用視而不見——而在多時間框架策略中,互動作用幾乎總是存在的。

通過Optuna的貝葉斯最佳化(TPE、GP、CmaEs)整體探索參數空間。使用預計算的Parquet快取,500次迭代8分鐘——能找到OAT看不到的組合。

多目標最佳化(NSGA-III)將"最大化PnL"的問題轉化為"構建PnL vs MaxDD的帕累托前沿"——提供一組具有不同風險收益平衡的解。

但最佳化只是流程的一部分。找到的參數需要通過Monte Carlo自舉法驗證,根據資金費率校正,考慮活躍時間重新計算,並通過Walk-Forward驗證。更多內容將在系列後續文章中介紹。


參考連結

  1. Optuna: A Next-generation Hyperparameter Optimization Framework (Akiba et al., 2019)
  2. Algorithms for Hyper-Parameter Optimization (Bergstra et al., 2011) — TPE原始論文
  3. Optuna Documentation — Samplers
  4. Optuna Visualization Module
  5. Hansen, N. — The CMA Evolution Strategy: A Tutorial
  6. Deb, K. et al. — NSGA-II: A Fast and Elitist Multiobjective Genetic Algorithm (2002)
  7. Snoek, J. et al. — Practical Bayesian Optimization of Machine Learning Algorithms (2012)
  8. Freqtrade Documentation — Hyperopt
  9. Marcos Lopez de Prado — Advances in Financial Machine Learning, Chapter 12
  10. Bergstra, J. & Bengio, Y. — Random Search for Hyper-Parameter Optimization (2012)

引用

@article{soloviov2026optuna,
  author = {Soloviov, Eugen},
  title = {Coordinate Descent vs Bayesian Optimization: Which Finds Better Parameters},
  year = {2026},
  url = {https://marketmaker.cc/zh/blog/post/optuna-vs-coordinate-descent},
  description = {为什么穷举搜索对12个以上参数不可行,坐标下降法如何遗漏交互作用,以及Optuna的TPE采样器如何在500次迭代中找到OAT在96次中无法找到的结果。}
}
免責宣告:本文提供的資訊僅用於教育和參考目的,不構成財務、投資或交易建議。加密貨幣交易涉及重大損失風險。

Authors

Eugen Soloviov
Eugen Soloviov

Trading-systems engineer

Trading-systems engineer building bots since 2017: cross-exchange arbitrage (connected up to 30 venues), cointegration-based pairs arbitrage across spot and futures, scalping, news and sentiment-driven strategies, trend algorithms, and portfolio management and balancing algorithms. Also builds sub-millisecond order execution, big-data warehouses, backtesting engines, AI agents, and trading interfaces (incl. open-source profitmaker.cc). Stack: JS/TS, Python, Rust/Zig/Go, DevOps, backend, frontend, architecture.

Newsletter

緊跟市場步伐

訂閱我們的時事通訊,獲取獨家 AI 交易見解、市場分析和平台更新。

我們尊重您的隱私。您可以隨時退訂。