CatBoost 模型中的交叉验证和因果推理基础及导出为 ONNX 格式·进阶篇
📘

CatBoost 模型中的交叉验证和因果推理基础及导出为 ONNX 格式·进阶篇

第 2/3 篇

训练前的超参数怎么填

跑模型前先把输入参数对齐,不然训练结果会偏。路径指向终端 Include 文件夹,用来落训练好的模型文件;SYMBOL 写交易品种,MARKUP 把点差、佣金加滑点一起折算成价格偏移,EURUSD 示例里给了 0.00015。 PERIODS 是算价格增量的移动平均周期集合,代码里取 10 到 40 步、每隔 10 取一个,也就是 [10,20,30,40],这些周期直接决定喂给模型的属性维度。 BACKWARD 和 FORWARD 框出训练日期范围,示例是 2015-01-01 到 2022-01-01;这段区间左右两侧没参与训练的历史属于 OOS,拿来测泛化。外汇和贵金属杠杆高、滑点跳空频繁,MARKUP 设小了回测会虚胖,实盘可能直接打脸。 get_labels 里的 min 和 max 控制随机抽交易的持续时长,单位大概是根数或 bar 数;两边设成一样就变成固定持仓长度,设成 3 和 25 则每笔随机在 3~25 之间。 下面这段是原文里的 Python 配置骨架,路径和参数照抄到你的环境要改成本机 MT5 的 Include 实际位置。

MQL5 / C++
export_path = &class="macro">#x27;/Users/dmitrievsky/Library/Application Support/MetaTrader class="num">5/\Bottles/metatrader5/drive_c/Program Files/MetaTrader class="num">5/MQL5/Include/&class="macro">#x27;
# GLOBALS
SYMBOL = &class="macro">#x27;EURUSD&class="macro">#x27;
MARKUP = class="num">0.00015
PERIODS = [i for i in range(class="num">10, class="num">50, class="num">10)]
BACKWARD = class="type">class="kw">datetime(class="num">2015, class="num">1, class="num">1)
FORWARD = class="type">class="kw">datetime(class="num">2022, class="num">1, class="num">1)
def get_labels(dataset, min= class="num">3, max= class="num">25) -> pd.DataFrame:

「把报价变成可训练特征与标签的本地管道」

做 ML 建模前,先把 MT5 导出的 EURUSD_H1 报价落到 Python 项目的 /files 子目录,文件名约定为 files/EURUSD_H1.csv,用空格分隔字段。读取后只保留 time 与 close 两列,并把 time 转成 datetime 索引,这一步直接决定后续滚动窗口能否对齐。 特征构造的核心在 get_prices():从 PERIODS 列表里逐个取窗口长度,用对应周期的滚动均值减去收盘价,生成若干列差值特征。PERIODS 是超参数,长度组合不同,模型捕捉的均值回归偏移尺度也不同。 标签函数 get_labels() 用随机步长 rand = random.randint(3, 25) 在未来 3~25 根 bar 内取价,比较未来价与当前 close 得到三分类 labels(涨/跌/持平),再用 MARKUP 阈值过滤出 meta_labels 作为次级标签。持平类(labels==2.0)在返回前被整体丢弃,只留多空样本。 tester() 已重写为纯数据帧驱动:它不再关心数据来自原始训练集还是模型预测后的输出,只要传入的 dataframe 带 labels、meta_labels 和 close 就能跑回测。这意味着你可以把训练好的 CatBoost 预测概率拼回原 dataframe,直接丢进同一个 tester 比对基准。 让小布替你跑这套:把 PERIODS 改成 [5, 15, 60] 重新生成特征,观察 tester 在 EURUSD_H1 上多空样本分布是否偏移,外汇与贵金属杠杆品类波动剧烈,回测盈利不代表实盘概率占优。

MQL5 / C++
class="kw">import numpy as np
class="kw">import pandas as pd
class="kw">import random
class="kw">import math
from class="type">class="kw">datetime class="kw">import class="type">class="kw">datetime
class="kw">import matplotlib.pyplot as put
from catboost class="kw">import CatBoostClassifier
from sklearn.model_selection class="kw">import train_test_split
from sklearn.linear_model class="kw">import LinearRegression
from sklearn.model_selection class="kw">import cross_val_predict
def get_prices() -> pd.DataFrame:
    p = pd.read_csv(&class="macro">#x27;files/EURUSD_H1.csv&class="macro">#x27;, delim_whitespace=True)
    pFixed = pd.DataFrame(columns=[&class="macro">#x27;time&class="macro">#x27;, &class="macro">#x27;close&class="macro">#x27;])
    pFixed[&class="macro">#x27;time&class="macro">#x27;] = p[&class="macro">#x27;<DATE>&class="macro">#x27;] + &class="macro">#x27; &class="macro">#x27; + p[&class="macro">#x27;<TIME>&class="macro">#x27;]
    pFixed[&class="macro">#x27;time&class="macro">#x27;] = pd.to_datetime(pFixed[&class="macro">#x27;time&class="macro">#x27;], format=&class="macro">#x27;mixed&class="macro">#x27;)
    pFixed[&class="macro">#x27;close&class="macro">#x27;] = p[&class="macro">#x27;<CLOSE>&class="macro">#x27;]
    pFixed.set_index(&class="macro">#x27;time&class="macro">#x27;, inplace=True)
    pFixed.index = pd.to_datetime(pFixed.index, unit=&class="macro">#x27;s&class="macro">#x27;)
    pFixed = pFixed.dropna()
    pFixedC = pFixed.copy()
    count = class="num">0
    for i in PERIODS:
        pFixed[str(count)] = pFixedC.rolling(i).mean() - pFixedC
        count += class="num">1
    class="kw">return pFixed.dropna()
def get_labels(dataset, min= class="num">3, max= class="num">25) -> pd.DataFrame:
    labels = []
    meta_labels = []
    for i in range(dataset.shape[class="num">0]-max):
        rand = random.randint(min, max)
        curr_pr = dataset[&class="macro">#x27;close&class="macro">#x27;][i]
        future_pr = dataset[&class="macro">#x27;close&class="macro">#x27;][i + rand]
        if future_pr < curr_pr:
            labels.append(class="num">1.0)
            if future_pr + MARKUP < curr_pr:
                meta_labels.append(class="num">1.0)
            else:
                meta_labels.append(class="num">0.0)
        elif future_pr > curr_pr:
            labels.append(class="num">0.0)
            if future_pr - MARKUP > curr_pr:
                meta_labels.append(class="num">1.0)
            else:
                meta_labels.append(class="num">0.0)
        else:
            labels.append(class="num">2.0)
            meta_labels.append(class="num">0.0)
    dataset = dataset.iloc[:len(labels)].copy()
    dataset[&class="macro">#x27;labels&class="macro">#x27;] = labels
    dataset[&class="macro">#x27;meta_labels&class="macro">#x27;] = meta_labels
    dataset = dataset.dropna()
    dataset = dataset.drop(
        dataset[dataset.labels == class="num">2.0].index)
    class="kw">return dataset
def tester(dataset: pd.DataFrame, plot= False):
    last_deal = class="type">int(class="num">2)
    last_price = class="num">0.0
    report = [class="num">0.0]
    chart = [class="num">0.0]
    line = class="num">0
    line2 = class="num">0
    indexes = pd.DatetimeIndex(dataset.index)
    labels = dataset[&class="macro">#x27;labels&class="macro">#x27;].to_numpy()
    metalabels = dataset[&class="macro">#x27;meta_labels&class="macro">#x27;].to_numpy()
    close = dataset[&class="macro">#x27;close&class="macro">#x27;].to_numpy()
    for i in range(dataset.shape[class="num">0]):

◍ 回测里怎么用双标签卡住出场点

这段逻辑跑在 Python 侧,但思路可以直接搬进 MT5 的 EA 事件循环:用主标签判方向,用元标签(metalabels)当「是否允许交易」的闸门。原文里 pred_meta==1 才放行,等于把模型认为「行情不值得下手」的时段全过滤掉,外汇和贵金属这种高波动品种尤其该这么干,否则噪声时段会吃掉样本外收益。 核心状态机就三个值:last_deal=0 等做多信号,=1 等做空了结,=2 表示刚平完仓在观望。当 last_deal==0 且 pred>0.5 且元标签通过,就记录 last_price 并把状态切到 2,report 里扣掉 MARKUP 点差成本再加上价格差——这一步和 MT5 里 OrderClose 后算净值变动是一回事。 样本外切分靠 FORWARD 和 BACKWARD 两个索引,line 和 line2 分别标出两段 OOS 边界,画图时紫色和红色虚线就是这么来的。最后用 LinearRegression 对交易序号和累计 pip 收益做拟合,lr.score(X,y)*l 的符号决定策略斜率方向:l 为正输出 1,为负输出 -1,相当于给策略一个「样本外倾向盈利/亏损」的定性判分,而不是拍胸脯说能赚多少。 真要验证,把这段代码里的 report.append 逻辑改成 MT5 的 OnTradeTransaction 里累加 AccountInfoDouble(ACCOUNT_EQUITY),把 pred_meta 阈值从 0.5 调到 0.6 看看样本外 R² 是不是掉得更慢,这比看任何回测截图都实在。外汇与贵金属杠杆交易风险极高,样本外判分仅作概率参考。

MQL5 / C++
    if indexes[i] <= FORWARD:
        line = len(report)
    if indexes[i] <= BACKWARD:
        line2 = len(report)
    pred = labels[i]
    pr = close[i]
    pred_meta = metalabels[i] # class="num">1 = allow trades
    if last_deal == class="num">2 and pred_meta==class="num">1:
        last_price = pr
        last_deal = class="num">0 if pred <= class="num">0.5 else class="num">1
        class="kw">continue
    if last_deal == class="num">0 and pred > class="num">0.5 and pred_meta == class="num">1:
        last_deal = class="num">2
        report.append(report[-class="num">1] - MARKUP + (pr - last_price))
        chart.append(chart[-class="num">1] + (pr - last_price))
        class="kw">continue
    if last_deal == class="num">1 and pred < class="num">0.5 and pred_meta==class="num">1:
        last_deal = class="num">2
        report.append(report[-class="num">1] - MARKUP + (last_price - pr))
        chart.append(chart[-class="num">1] + (pr - last_price))
    y = np.array(report).reshape(-class="num">1, class="num">1)
    X = np.arange(len(report)).reshape(-class="num">1, class="num">1)
    lr = LinearRegression()
    lr.fit(X, y)
    l = lr.coef_
    if l >= class="num">0:
        l = class="num">1
    else:
        l = -class="num">1
    if(plot):
        plt.plot(report)
        plt.plot(chart)
        plt.axvline(x = line, class="type">class="kw">color=&class="macro">#x27;purple&class="macro">#x27;, ls=&class="macro">#x27;:&class="macro">#x27;, lw=class="num">1, label=&class="macro">#x27;OOS&class="macro">#x27;)
        plt.axvline(x = line2, class="type">class="kw">color=&class="macro">#x27;red&class="macro">#x27;, ls=&class="macro">#x27;:&class="macro">#x27;, lw=class="num">1, label=&class="macro">#x27;OOS2&class="macro">#x27;)
        plt.plot(lr.predict(X))
        plt.title("Strategy performance R^class="num">2 " + str(format(lr.score(X, y) * l,".2f")))
        plt.xlabel("the number of trades")
        plt.ylabel("cumulative profit in pips")
        plt.show()
    class="kw">return lr.score(X, y) * l
def test_model(result: list, plt= False):
    pr_tst = get_prices()
    X = pr_tst[pr_tst.columns[class="num">1:]]
    pr_tst[&class="macro">#x27;labels&class="macro">#x27;] = result[class="num">0].predict_proba(X)[:,class="num">1]
    pr_tst[&class="macro">#x27;meta_labels&class="macro">#x27;] = result[class="num">1].predict_proba(X)[:,class="num">1]
    pr_tst[&class="macro">#x27;labels&class="macro">#x27;] = pr_tst[&class="macro">#x27;labels&class="macro">#x27;].apply(lambda x: class="num">0.0 if x < class="num">0.5 else class="num">1.0)
    pr_tst[&class="macro">#x27;meta_labels&class="macro">#x27;] = pr_tst[&class="macro">#x27;meta_labels&class="macro">#x27;].apply(lambda x: class="num">0.0 if x < class="num">0.5 else class="num">1.0)
    class="kw">return tester(pr_tst, plot=plt)

用 R² 挑出能跑 OOS 的五个模型

超参数定下来后,模型训练就是个循环活。这一轮我们直接训 25 个,再测一遍并把表现靠前的导进 MT5 终端。训练结果最吃三样东西:你选的参数、训练与测试用的日期区间、还有单笔交易持续时间,所以这些设置值得反复试。 按 R² 排个序,留前 5 个去测新数据。图里水平线分左右两侧 OOS,蓝线是余额、橙线是报价。25 个模型长相各不相同,根源在交易随机抽样加模型内置随机化;但它们已不像找圣杯,在 OOS 里跑得比较自由。还能比交易笔数、盈利点数和曲线形态——头两名明显占优,就导出这两个。 要记住,外汇和贵金属是高风市场,改训练参数、多重启几次,行为就会变,图几乎不会重复;但其中相当比例在 OOS 上可能给出不错结果,概率上值得做实验。 下面这段是 25 模型训练与筛选的 Python 原型,核心循环和排序逻辑可直接照搬到你的实验脚本里。

MQL5 / C++
options = []
for i in range(class="num">25):
    print(&class="macro">#x27;Learn &class="macro">#x27; + str(i) + &class="macro">#x27; model&class="macro">#x27;)
    options.append(learn_final_models(meta_learner(folds_number= class="num">5, iter= class="num">150, depth= class="num">5, l_rate= class="num">0.01)))
options.sort(key=lambda x: x[class="num">0])
test_model(options[-class="num">1][class="num">1:], plt=True)

「把 CatBoost 模型丢进 ONNX 给 MT5 用」

MT5 终端现在原生吃 ONNX,意味着你不必再手写 cpp 到 MQL 的解析层,几乎任何 Python 训出来的模型都能直接搬。CatBoost 自带 save_model 的 onnx 导出接口,比自己序列化权重省事太多。 导出函数一次收两个模型:主模型 catmodel 和元模型 catmodel_m,各自带 onnx_domain、graph_name 等元信息写进磁盘,同时顺手生成一份 .mqh 头文件,把 #resource 二进制挂进 Expert 的编译资源里。下面这段就是导出核心,注意 model[1]、model[2] 分别落盘,文件名用交易品种和序号拼出来。 [CODE] def export_model_to_ONNX(model, model_number): model[1].save_model( export_path +'catmodel' + str(model_number) +'.onnx', format="onnx", export_parameters={ 'onnx_domain': 'ai.catboost', 'onnx_model_version': 1, 'onnx_doc_string': 'test model for BinaryClassification', 'onnx_graph_name': 'CatBoostModel_for_BinaryClassification' }, pool=None) model[2].save_model( export_path + 'catmodel_m' + str(model_number) +'.onnx', format="onnx", export_parameters={ 'onnx_domain': 'ai.catboost', 'onnx_model_version': 1, 'onnx_doc_string': 'test model for BinaryClassification', 'onnx_graph_name': 'CatBoostModel_for_BinaryClassification' }, pool=None) code = '#include <Math\Stat\Math.mqh>' code += '\n' code += '#resource "catmodel'+str(model_number)+'.onnx" as uchar ExtModel[]' code += '\n' code += '#resource "catmodel_m'+str(model_number)+'.onnx" as uchar ExtModel2[]' code += '\n' code += 'int Periods' + '[' + str(len(PERIODS)) + '] = {' + ','.join(map(str, PERIODS)) + '};' code += '\n\n' code += 'void fill_arays' + '( double &features[]) {\n' code += ' double pr[], ret[];\n' code += ' ArrayResize(ret, 1);\n' code += ' for(int i=ArraySize(Periods)-1; i>=0; i--) {\n' code += ' CopyClose(NULL,PERIOD_H1,1,Periods[i],pr);\n' code += ' ret[0] = MathMean(pr) - pr[Periods[i]-1];\n' code += ' ArrayInsert(features, ret, ArraySize(features), 0, WHOLE_ARRAY); }\n' code += ' ArraySetAsSeries(features, true);\n' code += '}\n\n' file = open(export_path + str(SYMBOL) + ' ONNX include' + str(model_number) + '.mqh', "w") file.write(code) file.close() print('The file ' + 'ONNX include' + '.mqh ' + 'has been written to disk') #resource "catmodel.onnx" as uchar ExtModel[] #resource "catmodel_m.onnx" as uchar ExtModel2[] #include <Math\Stat\Math.mqh> int Periods[4] = {10,20,30,40}; void fill_arays( double &features[]) { double pr[], ret[]; 在 Netron 里打开 onnx,你会看到两个输出节点。第一个是类标签的 unit tensor,但 CatBoost 文档明说二元分类下 onnxruntime 有已知 bug,标签推断不正确,所以直接弃用。第二个 probabilities 才是拿来预测的——我在 MQL 里没显式声明它,运行时却自动绑上了,类概率从 tensor 数组第二项取。 机器人侧先 fill_arays 填特征,再把数组反序丢给 OnnxRuntime。主模型给 sig,元模型给 meta_sig;meta_sig 大于 0.5 才放行,按 sig 方向开平仓。外汇和贵金属波动剧烈、杠杆高风险大,这套信号仅作概率参考,实盘前务必在 MT5 策略测试器跑通导出链路。

MQL5 / C++
def export_model_to_ONNX(model, model_number):
    model[class="num">1].save_model(
    export_path +&class="macro">#x27;catmodel&class="macro">#x27; + str(model_number) +&class="macro">#x27;.onnx&class="macro">#x27;,
    format="onnx",
    export_parameters={
        &class="macro">#x27;onnx_domain&class="macro">#x27;: &class="macro">#x27;ai.catboost&class="macro">#x27;,
        &class="macro">#x27;onnx_model_version&class="macro">#x27;: class="num">1,
        &class="macro">#x27;onnx_doc_string&class="macro">#x27;: &class="macro">#x27;test model for BinaryClassification&class="macro">#x27;,
        &class="macro">#x27;onnx_graph_name&class="macro">#x27;: &class="macro">#x27;CatBoostModel_for_BinaryClassification&class="macro">#x27;
    },
    pool=None)
    model[class="num">2].save_model(
    export_path + &class="macro">#x27;catmodel_m&class="macro">#x27; + str(model_number) +&class="macro">#x27;.onnx&class="macro">#x27;,
    format="onnx",
    export_parameters={
        &class="macro">#x27;onnx_domain&class="macro">#x27;: &class="macro">#x27;ai.catboost&class="macro">#x27;,
        &class="macro">#x27;onnx_model_version&class="macro">#x27;: class="num">1,
        &class="macro">#x27;onnx_doc_string&class="macro">#x27;: &class="macro">#x27;test model for BinaryClassification&class="macro">#x27;,
        &class="macro">#x27;onnx_graph_name&class="macro">#x27;: &class="macro">#x27;CatBoostModel_for_BinaryClassification&class="macro">#x27;
    },
    pool=None)

    code = &class="macro">#x27;class="macro">#include <Math\Stat\Math.mqh>&class="macro">#x27;
    code += &class="macro">#x27;\n&class="macro">#x27;
    code += &class="macro">#x27;class="macro">#resource "catmodel&class="macro">#x27;+str(model_number)+&class="macro">#x27;.onnx" as class="type">uchar ExtModel[]&class="macro">#x27;
    code += &class="macro">#x27;\n&class="macro">#x27;
    code += &class="macro">#x27;class="macro">#resource "catmodel_m&class="macro">#x27;+str(model_number)+&class="macro">#x27;.onnx" as class="type">uchar ExtModel2[]&class="macro">#x27;
    code += &class="macro">#x27;\n&class="macro">#x27;
    code += &class="macro">#x27;class="type">int Periods&class="macro">#x27; + &class="macro">#x27;[&class="macro">#x27; + str(len(PERIODS)) + &class="macro">#x27;] = {&class="macro">#x27; + &class="macro">#x27;,&class="macro">#x27;.join(map(str, PERIODS)) + &class="macro">#x27;};&class="macro">#x27;
    code += &class="macro">#x27;\n\n&class="macro">#x27;
    code += &class="macro">#x27;class="type">void fill_arays&class="macro">#x27; + &class="macro">#x27;( class="type">class="kw">double &features[]) {\n&class="macro">#x27;
    code += &class="macro">#x27;   class="type">class="kw">double pr[], ret[];\n&class="macro">#x27;
    code += &class="macro">#x27;   ArrayResize(ret, class="num">1);\n&class="macro">#x27;
    code += &class="macro">#x27;   for(class="type">int i=ArraySize(Periods)-class="num">1; i>=class="num">0; i--) {\n&class="macro">#x27;
    code += &class="macro">#x27;       CopyClose(NULL,PERIOD_H1,class="num">1,Periods[i],pr);\n&class="macro">#x27;
    code += &class="macro">#x27;       ret[class="num">0] = MathMean(pr) - pr[Periods[i]-class="num">1];\n&class="macro">#x27;
    code += &class="macro">#x27;       ArrayInsert(features, ret, ArraySize(features), class="num">0, WHOLE_ARRAY); }\n&class="macro">#x27;
    code += &class="macro">#x27;   ArraySetAsSeries(features, true);\n&class="macro">#x27;
    code += &class="macro">#x27;}\n\n&class="macro">#x27;
    file = open(export_path + str(SYMBOL) + &class="macro">#x27; ONNX include&class="macro">#x27; + str(model_number) + &class="macro">#x27;.mqh&class="macro">#x27;, "w")
    file.write(code)
    file.close()
    print(&class="macro">#x27;The file &class="macro">#x27; + &class="macro">#x27;ONNX include&class="macro">#x27; + &class="macro">#x27;.mqh &class="macro">#x27; + &class="macro">#x27;has been written to disk&class="macro">#x27;)
class="macro">#resource "catmodel.onnx" as class="type">uchar ExtModel[]
class="macro">#resource "catmodel_m.onnx" as class="type">uchar ExtModel2[]
class="macro">#include <Math\Stat\Math.mqh>
class="type">int Periods[class="num">4] = {class="num">10,class="num">20,class="num">30,class="num">40};
class="type">void fill_arays( class="type">class="kw">double &features[]) {
   class="type">class="kw">double pr[], ret[];

常见问题

iterations和learning_rate要一起调,新手常把iterations设太高又不开早停;depth建议先从4~6试,配合border_count控制分箱粒度。
用pandas按时间窗滚动抽取高低收和波动幅度作特征,标签用未来N根是否破ATR阈值生成;存成parquet比csv读取快数倍。
可以。小布盯盘的AIGC管道支持上传特征表后自动做K折交叉验证,按R²和OOS衰减排序,直接给出可导出模型清单。
标签1管止盈位触发、标签2管止损位触发,两条都只用已闭合K线计算;出场逻辑写进onTick前先校验bar时间。
九成是特征名和训练时不一致导致重排;导出前用model.get_feature_names()打印对照,推理端按同序灌数组即可。