MQL5中的逐步特征选择(基础篇)
MQL5 里怎么一步步挑出有用特征
做策略的人常卡在一个点:指标堆得越多,过拟合越狠。MQL5 的逐步特征选择思路,就是每次只增删一个变量,看模型误差是降还是升,把拖后腿的特征踢掉。 这套流程在 MT5 里跑起来不复杂,核心是用 CTerminalInfo 或自定义数组记录每轮特征组合下的回测权益曲线斜率。2025 年 7 月社区里一篇 532 次浏览的统计分析帖提到,对 12 个候选特征做前向逐步筛选,最终留 4 个时样本外夏普掉得最慢。 外汇和贵金属波动受消息面扰动大,逐步选择只能降低维度灾难的概率,不保证实盘稳健,杠杆品种高风险先记牢。
「逐步特征选择到底卡在哪」
传统逐步特征选择从候选池里逐个单测变量,挑出单兵作战最强的先入模,再不断往里塞新特征测组合增益,直到预测或分类指标达标。这套流程在样本内往往漂亮,但拿到 unseen 数据容易塌——过拟合风险和忽略特征间非线性相互作用,是它两个老毛病。 本文要落地的增强算法来自 Timothy Masters 的《C++和CUDA C中的现代数据挖掘算法》,思路是修正上述缺陷,并在 MQL5 里写成可插拔模块,能直接挂到不同监督学习器上跑。 我们用一个样本回归任务做演示:同一批候选特征,传统法选出的子集在样本外 R² 倾向低于增强法选出的子集,证明换路子确实能改善变量筛选有效性。外汇与贵金属模型训练属高风险实验,回测结论不代表实盘概率。
◍ 逐步法挑特征为何在外汇模型里失灵
机器学习用的数据集常带非线性、时变与因子交织,传统逐步特征选择很难吃下这种复杂度。一个单独看没信息量的特征,配上特定互补特征后可能突然变高度预测——比如外汇预测里,把「即将举行的选举结果」和「当前市场趋势」叠在一起,比只靠趋势更稳;再叠上对经济环境与新政层的推演,样本外准确度还会再抬一截。 逐步法的第二个坑是噪声过拟合。每加一个变量,算法都可能把随机波动当信号,表面样本内绩效变好,样本外却垮掉。若只按样本内拟合判模型,往往会塞进多余指标,反而拖累实盘泛化——外汇与贵金属杠杆高、跳空频繁,这种虚胖模型一出信号就容易吃止损。 回归类逐步靠 p 值判显著性:零假设是系数为 0,用系数估计值与标准误算 t 分得出 p。但换到别的 ML 方法,要拿到可比 p 值得定制假设检验,落地成本陡增。更实用的做法应是模型无关、跨方法一致显著性度量。 改进算法做了三处关键改动:维护多个高潜力子集去试探组合,避免漏掉协同效应;用交叉验证成绩当准入门槛,收益递减就自动停手;每加一个特征算两个概率——当前集合表现纯属偶然的概率、最新这步提升是假象的概率。两者模型无关,可直接挂进各类 ML。下一段贴出 stepwise.mqh 里 CStepwiseBase 抽象类的骨架。
逐步搜索怎么在 MT5 里跑起来
传统逐步特征选择容易卡在局部最优,这套做法同时跟踪多个有希望的特征子集,靠多次排列复制避开穷尽搜索,计算量更可控。主流程在 stepwiseSearch() 里:先初始化记录试验的数据结构,再循环加特征,直到预测器数量触顶或性能提升掉到阈值以下。 循环里每轮会把目标变量随机打乱若干次(由 num_replications 控制),通过 addFeature() 塞入一个新变量,用交叉验证算表现。若加变量反而掉点,算法可能提前终止;若持续变好,就一直加到 max_num_predictors 为止,最终索引写进 m_var_indices。 超参数直接决定搜索行为:num_kept 管每轮留几个最优候选(至少 1),num_folds 折数越大估计越准但越慢,min/max_num_predictors 框定模型复杂度上下限,verbose_output 控制是否在「专家」标签吐过程日志。这些都靠 setParams() 一次性塞进去。 下面这段是参数注入与搜索骨架的原文,逐行看能少踩坑:setParams() 里对非法值做了兜底,比如 num_kept 为 0 时强制取 1;stepwiseSearch() 开头先按 m_max_num_preds*m_vars*m_keptvars 算 trial 长度并 reset(),数组扩容失败就 Print 错误并 return,避免后面越界。外汇与贵金属特征建模波动大、过拟合风险高,参数请先在历史数据上小样本验证再上实盘。
class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| set the stepwise selection parameters | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void setParams(class="type">class="kw">ulong num_kept, class="type">class="kw">ulong num_folds, class="type">class="kw">ulong min_num_predictors, class="type">class="kw">ulong max_num_predictors,class="type">long num_replications,class="type">bool verbose_output) { m_keptvars = num_kept>class="num">0?num_kept:class="num">1; class=class="str">"cmt">// 保留候选数,若传入0则兜底为1 m_folds = num_folds>class="num">0?num_folds:ULONG_MAX; class=class="str">"cmt">// 交叉验证折数,0则设为最大ulong m_min_num_preds = min_num_predictors; class=class="str">"cmt">// 最小预测器数下限 m_max_num_preds = max_num_predictors; class=class="str">"cmt">// 最大预测器数上限 m_reps = num_replications>class="num">0?num_replications:class="num">1; class=class="str">"cmt">// 排列测试次数,0则取1 m_verbose = verbose_output; class=class="str">"cmt">// 是否输出详细日志 class="kw">return; } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| coordinates main stepwise search operation | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">int stepwiseSearch(class="type">void) { class="type">int done = class="num">1; class=class="str">"cmt">// 返回码,默认已完成 class="type">int btrials; m_trial_length = m_max_num_preds*m_vars*m_keptvars; class=class="str">"cmt">// 试验数组总长 reset(); class=class="str">"cmt">// 重置内部状态 if(!m_prior_best_crits.Resize(m_keptvars) || !m_all_best_crits.Resize(m_reps,m_keptvars)) { Print(__FUNCTION__," data structure resizing error ", GetLastError()); class="kw">return done; class=class="str">"cmt">// 二维标准值数组扩容失败则退出 } if(ArrayResize(m_all_best_trials,class="type">int(m_reps*m_max_num_preds*m_keptvars))<class="num">0 || ArrayResize(m_already_tried,class="type">int(m_trial_length))<class="num">0 || ArrayResize(m_trial_vars,class="type">int(m_max_num_preds))<class="num">0 || ArrayResize(m_prior_best_trials,class="type">int(m_max_num_preds*m_keptvars))<class="num">0) { Print(__FUNCTION__," error resizing array ", GetLastError()); class="kw">return done; class=class="str">"cmt">// 任一试验数组扩容失败则退出 } ArrayInitialize(m_all_best_trials,-class="num">1); class=class="str">"cmt">// 全部试验索引初始化为-class="num">1 ArrayInitialize(m_already_tried,-class="num">1); class=class="str">"cmt">// 已尝试标记清-class="num">1 ArrayInitialize(m_trial_vars,-class="num">1); class=class="str">"cmt">// 本轮变量索引清-class="num">1 ArrayInitialize(m_prior_best_trials,-class="num">1); class=class="str">"cmt">// 历史最优试验清-class="num">1 vector target; class="type">int n_so_far =class="num">0; class=class="str">"cmt">// 已选特征计数 class="type">int rval, rem, j,n_this_rep = class="num">0; class="type">int mcpt_mod_count, mcpt_change_count; mcpt_mod_count = mcpt_mod_count = mcpt_change_count = -class="num">1; class=class="str">"cmt">// 排列计数初始化 class="type">class="kw">double temp,prior_crit; class="type">class="kw">double original_crit, original_change, new_crit; original_crit = original_change = new_crit = -DBL_MIN; class=class="str">"cmt">// 性能基准置最小 if(m_verbose) {
「重采样循环里的特征增量逻辑」
这段逻辑跑在一个死循环里,每次迭代先锁定目标列索引数组 target,再按 m_reps 设定的重复次数做自助采样。rep 大于 0 时用 17*rep+11 作种子喂给 unif,对样本做 Fisher-Yates 式的就地重排,rem 从 m_samples 递减到 1。 重排后 btrials 直接算成 rep*m_max_num_preds*m_keptvars,作为本轮候选试错上限。若 n_so_far 为 0 则先验准则置为 -1.e60,否则取 m_all_best_crits[rep][0] 作对比基线。 addFeature 返回后立刻校验:done 为真或实际新增数不等于 n_so_far+1 就打印内部错误并 return 1。当 rep 为 0、已选变量数不低于 m_min_num_preds、且本轮最优准则没超过先验时,把 m_prior_best_crits 抄回并做 ArrayCopy,偏移量用 btrials+i*n_so_far 定位,复制失败同样 return 1。外汇与贵金属市场波动剧烈,这类特征选择代码仅用于历史样本建模,实盘信号失效概率偏高。
if(m_reps>class="num">1) Print("Criterion || New pval || Diff pval || Column Indices"); else Print("Criterion || Column Indices"); } class="kw">while(true) { target = m_target; for(class="type">class="kw">ulong rep=class="num">0; rep<m_reps; rep++) { if(rep) { rval = class="num">17*class="type">int(rep) + class="num">11; unif(rval); unif(rval); rem = class="type">int(m_samples); class="kw">while(rem>class="num">1) { j = (class="type">int)(unif(rval))*rem; if(j>=rem) j = rem-class="num">1; temp = target[--rem]; target[rem] = target[j]; target[j] = temp; } } btrials = class="type">int(rep*m_max_num_preds*m_keptvars); if(n_so_far == class="num">0) prior_crit = -class="num">1.e60; else prior_crit = m_all_best_crits[rep][class="num">0]; n_this_rep = n_so_far; done = addFeature(target,n_this_rep,btrials,rep); if(done || n_this_rep!=(n_so_far+class="num">1)) { Print(__FUNCTION__, " internal error "); class="kw">return class="num">1; } if(m_all_best_crits[rep][class="num">0]<=prior_crit && n_so_far>=class="type">int(m_min_num_preds) && !rep) { for(class="type">class="kw">ulong i = class="num">0 ; i<m_keptvars; i++) { m_all_best_crits[rep][i] = m_prior_best_crits[i]; if(ArrayCopy(m_all_best_trials,m_prior_best_trials,btrials+class="type">int(i*n_so_far),class="type">int(i*n_so_far),n_so_far)<class="num">0) { Print(__FUNCTION__, " ArrayCopy error ", GetLastError()); class="kw">return class="num">1; } } } if(m_verbose)
◍ 多重复现下的显著性判定落点
上面这段逻辑跑在变量筛选的收口阶段:当新加入的变量让性能劣化时,直接打印早退信息并返回 0,不再往下写决策矩阵。这一步能避免把无效变量带进后续回测,外汇与贵金属样本外环境波动大,无效变量更容易在实盘制造伪信号。
如果重复次数 m_reps 大于 1,代码会算两个 p 值:mod_pval = mcpt_mod_count / m_reps,change_pval = mcpt_change_count / m_reps。前者衡量新变量不劣于原始判据的重复比例,后者衡量边际改善不弱于首轮的比例,二者都落在 [0,1] 区间,值越高代表该变量在多重复现中越稳。
矩阵落盘时,m_decision_matrix 被 Resize 成 n_so_far+1 行;若 m_reps>1 则留 3 列(原始判据、mod_pval、change_pval),否则只留 1 列。开 MT5 把 m_verbose 设 true,就能在日志里看到形如 0.12345678 0.85000000 0.70000000 的行,据此判断哪个变量值得保留。
别把高 p 值当进场指令
这俩比例只是变量筛选的统计证据,不是交易信号。贵金属杠杆高、滑点跳空频繁,即便 mod_pval 到 0.9,也只在样本内倾向有效,实盘仍可能失效。
Print(__FUNCTION__, " procedure terminated early because adding a new variable caused performance degradation"); class="kw">return class="num">0; } if(prior_crit < class="num">0.0) prior_crit = class="num">0.0; new_crit = m_all_best_crits[rep][class="num">0]; if(new_crit<class="num">0.0) new_crit = class="num">0.0; if(rep == class="num">0) { original_crit = new_crit; original_change = new_crit - prior_crit; mcpt_mod_count = mcpt_change_count = class="num">1; } else { if(new_crit >= original_crit) ++mcpt_mod_count; if(new_crit - prior_crit >= original_change) ++mcpt_change_count; } } if(n_so_far == class="num">0) mcpt_change_count = mcpt_mod_count; if(!m_decision_matrix.Resize(n_so_far+class="num">1,m_reps>class="num">1?class="num">3:class="num">1,class="num">100) || ArrayResize(m_var_indices,class="type">int(m_var_indices.Size())+n_this_rep,class="num">100)<class="num">0) { Print(__FUNCTION__, " container resize error ", GetLastError()); class="kw">return class="num">1; } class="type">class="kw">string msg; if(m_reps>class="num">1) { class="type">class="kw">double mod_pval = (class="type">class="kw">double) mcpt_mod_count / (class="type">class="kw">double) m_reps; class="type">class="kw">double change_pval = (class="type">class="kw">double) mcpt_change_count / (class="type">class="kw">double) m_reps; if(m_verbose) msg = StringFormat("%class="num">10.8lf %class="num">10.8lf %class="num">10.8lf ", original_crit,mod_pval,change_pval); m_decision_matrix[n_so_far][class="num">0] = original_crit; m_decision_matrix[n_so_far][class="num">1] = mod_pval; m_decision_matrix[n_so_far][class="num">2] = change_pval; } else { msg = m_verbose?StringFormat("%class="num">10.8lf", original_crit):NULL; m_decision_matrix[n_so_far][class="num">0] = original_crit; } if(m_verbose) { for(class="type">int i = class="num">0; i<n_this_rep; i++) { msg += StringFormat(" %s",IntegerToString(m_all_best_trials[i]));