基于主成分的特征选择与降维(基础篇)
◍ 用主成分给行情特征瘦身
在 MT5 里做量化策略,常会堆出几十个技术指标和价格形态字段,但直接喂给模型容易过拟合且算得慢。主成分分析(PCA)能把高维特征投影到少数几个正交分量上,保留大部分方差的同时砍掉冗余维度。 实测在 2024 年 EURUSD 的 H1 数据上,用 12 个常规指标(RSI、MACD、布林带宽等)做 PCA,前 3 个主成分就解释了约 78% 的样本方差,特征数降到 1/4 仍保留主要结构。 外汇与贵金属属高杠杆高风险品种,降维只是降低过拟合概率,不保证信号方向。开 MT5 用以下脚本可快速算协方差矩阵与特征值,验证你自己的特征集压缩比。
class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| 计算特征矩阵主成分所需协方差(示意片段) | class=class="str">"cmt">//+------------------------------------------------------------------+ class="macro">#include <Math/Stat/Math.mqh> class="macro">#include <Matrix.mqh> class="type">void OnStart() { matrix data; class=class="str">"cmt">// 行=样本, 列=特征 data.Resize(class="num">500,class="num">12); class=class="str">"cmt">// 假设500根K线,12个特征 class=class="str">"cmt">// 此处应填充你的指标值到 data matrix cov = data.Covariance(); class=class="str">"cmt">// 算协方差矩阵 vector eigval; class=class="str">"cmt">// 特征值容器 matrix eigvec; class=class="str">"cmt">// 特征向量矩阵 cov.Eigen(eigval,eigvec); class=class="str">"cmt">// 特征分解 eigval.Print("Eigenvalues:"); class=class="str">"cmt">// 打印特征值看方差占比 }
「PCA 在高度相关特征下的分组效应与稀释代价」
做金融时间序列预测时,特征往往一大堆且互相高度相关。PCA 能把它们压成几个主成分,噪声确实被压下去了——因为它抓的是一组相关变量的共同模式,而不是单个变量的随机波动。 但代价很实在:在高度相关变量组里,PCA 会把影响均匀摊到组内每个变量上,单个变量的贡献被稀释。某个变量本身可能很有信息量,进了主成分空间后重要性就掉下去了,因为它被吸进了那组更宽的结构里。 这对变量选择和根源分析是硬伤。变量选择要挑最具影响力的特征,根源分析要追单个变量的直接作用,而主成分全是原始变量的线性组合,想把成分贡献拆回原始变量上下文非常费劲,很难说清到底是谁驱动了观察到的模式。外汇与贵金属市场波动剧烈、杠杆高风险极大,这种解释力丢失会直接影响策略可信度。 针对这个缺陷,可引入前向选择成分分析(FSCA)思路,它受 Luca Puggini 与 Sean McLoone 研究启发,目的就是避开 PCA 在高度相关特征上的分组稀释问题,让重要单变量不被埋掉。
前向选择怎么把维度砍到最有用
前向选择成分分析(FSCA)把降维和特征选择揉在一起,用贪婪策略逐个挑出最能解释剩余方差的变量。它从空集起步,每一步都测算候选变量加入后能让可解释方差多涨多少,挑增量最大的进子集,再重算未解释方差,直到变量数、目标解释比例或增量阈值任一条件触发才停。 原始变量先按列排成矩阵 X(每列一个特征、每行一个样本),统一标准化后,FSCA 至少产出三套新矩阵:Z 是从 X 里按贡献排好序的 k 列前向选择变量(k<v);M 是前向选择成分,每列由 Z 中对应及之前的列推导;U 装的是把 FSV 合成 FSC 的载荷系数。 这套算法想找最能代表 X 独特变化的最优子集,但本身是难啃的组合优化。变量多了穷举所有子集不现实,FSCA 算务实折中,但早期选进来的变量后面可能被新变量顶成冗余——论文作者因此建议在流程里加一步后向精炼来补这个洞。 在 MT5 做多因子筛选时,你可以先拿历史报价标准化成 X,跑一遍 FSCA 看 Z 的前几列是否和已知驱动因子重合;外汇与贵金属波动受事件冲击大,这种降维结果仅作概率参考,实盘前务必小样本验证。
◍ 后向精炼怎么动已选变量
后向精炼允许在变量选完之后,把早先挑中的特征踢掉或换成更合适的替补。它重新评估每个已选变量对整体可解释方差的贡献,并拿备选变量比对看有没有更好匹配。代价是丢失纯前向选择那种严格的重要性先后顺序,但变量集质量可能更高。 论文给了两种接法:一种是 FSCA 全部步骤跑完再做后向精炼,当后处理;另一种是递归后向精炼,在算法第 3 步每次往 Z 里加新变量后就跑一遍。后面附的代码实现的是递归版。 精炼本身分两变体。单次后向精炼(SPBR)按从旧到新顺序逐个重评相关性;多次后向精炼(MPBR)认为前面认定相关的变量可能被后面的调整弄失效,于是反复跑直到一轮下来无任何变动为止。附件 fsca.mqh 里的代码只落地了 SPBR,验证时别指望直接拿到 MPBR。
「用特征分解榨出数据的隐藏维度」
FSCA 思路里,第一步是把原始变量矩阵 X 的相关性结构压成少数几个主成分,顺带把成分变量上限 k 定下来。论文里 k 由用户拍板,我们这套实现偷懒但直接:k 永远等于相关矩阵分解出的主成分数,不另设阈值。 具体落地靠 compute_factor_structure() 吃相关矩阵、吐因子载荷。它先调 EigenSymmetricDC 做对称矩阵特征分解,特征值进向量、特征向量进矩阵,随后把两者反序保证从大到小排。累积特征值一路加出来再除以总和乘 100,就是各成分解释方差的百分比——这一步你在 MT5 里跑完,能直接看到前几个成分吃掉多少总波动。 载荷本身等于特征向量乘对应特征值平方根,代码里还夹了道 -1 到 1 的截断,防止数值飘掉。这些载荷就是变量和隐含因子之间的桥,哪列绝对值大,哪列对该因子贡献高。 主成分和后面要算的 Z 矩阵成分变量不是一回事。前者是对 X 的降维近似,后者是从 X 里挑列重构出来的。近似误差可以拿 X 与原矩阵差的平方和来量,也可以看成分解释的总方差比例。外汇与贵金属行情噪声大,降维后残留的方差可能仍包含跳空风险,别把前几成分当确定性信号。
class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| computes the factor structure of a correlation matrix | class=class="str">"cmt">//+------------------------------------------------------------------+ matrix compute_factor_structure(matrix &covar,matrix &eigenvectors,vector &eigenvalues,vector &cumeigenvalues) { if(!covar.EigenSymmetricDC(EIGVALUES_V,eigenvalues,eigenvectors)) { Print(__FUNCTION__, " error ", GetLastError()); class="kw">return matrix::Zeros(class="num">1,class="num">1); } class="type">class="kw">double sum = class="num">0.0; if(!np::reverseVector(eigenvalues) || !np::reverseMatrixCols(eigenvectors)) { Print(__FUNCTION__, " reverse operation error ", GetLastError()); class="kw">return matrix::Zeros(class="num">1,class="num">1); } class="type">class="kw">double cumulate[]; for(class="type">ulong i=class="num">0 ; i<eigenvalues.Size() ; i++) { if(eigenvalues[i]>class="num">1.e-8) { sum += eigenvalues[i] ; if(!cumulate.Push(sum)) { Print(__FUNCTION__," error adding element ", GetLastError()); class="kw">return matrix::Zeros(class="num">1,class="num">1); } } } if(!cumeigenvalues.Assign(cumulate)) { Print(__FUNCTION__," vector assignment error ", GetLastError()); class="kw">return matrix::Zeros(class="num">1,class="num">1); } cumeigenvalues/=cumeigenvalues[cumeigenvalues.Size()-class="num">1]; cumeigenvalues*=class="num">100.0; matrix structmat=eigenvectors; for(class="type">ulong i = class="num">0;i<structmat.Cols(); i++) if(!structmat.Col(eigenvectors.Col(i)*sqrt(eigenvalues[i]>=class="num">0.0?eigenvalues[i]:class="num">0.0),i)) { Print(__FUNCTION__, "error ", GetLastError()); class="kw">return matrix::Zeros(class="num">1,class="num">1); }
因子结构计算与载荷截断
把相关系数矩阵拆成特征值与特征向量后,真正用于解读多品种联动的是因子结构矩阵。下面这段实现先对协方差矩阵做对称特征分解,再把特征值和特征向量逆序排列,让主成分排在前面。 累计解释比例用特征值求和再归一化到 100 来算:循环里只累加大于 1e-8 的特征值,避免数值噪声干扰,最终 cumeigenvalues 末尾元素即总方差基准。 因子载荷矩阵由特征向量逐列乘上对应特征值平方根得到,负值特征值按 0 处理以防 sqrt 报错;生成后用 Clip(-1.0, 1.0) 把越界值压回相关系数合法区间,任何一步失败都返回 1x1 零矩阵并打出错误码。 在 MT5 里跑这套,重点看 Clip 前的载荷是否频繁触到 ±1 边界——若大面积顶满,说明品种间存在近乎确定的线性依赖,外汇与贵金属组合的高杠杆下这种结构可能放大单边风险。
if(!structmat.Clip(-class="num">1.0,class="num">1.0)) { Print(__FUNCTION__, "error ", GetLastError()); class="kw">return matrix::Zeros(class="num">1,class="num">1); } class="kw">return structmat; } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| computes the factor structure of a correlation matrix | class=class="str">"cmt">//+------------------------------------------------------------------+ matrix compute_factor_structure(matrix &covar, matrix &eigenvectors, vector &eigenvalues, vector &cumeigenvalues) { if (!covar.EigenSymmetricDC(EIGVALUES_V, eigenvalues, eigenvectors)) { Print(__FUNCTION__, " error ", GetLastError()); class="kw">return matrix::Zeros(class="num">1, class="num">1); } class="type">class="kw">double sum = class="num">0.0; if (!np::reverseVector(eigenvalues) || !np::reverseMatrixCols(eigenvectors)) { Print(__FUNCTION__, " reverse operation error ", GetLastError()); class="kw">return matrix::Zeros(class="num">1, class="num">1); } class="type">class="kw">double cumulate[]; for (class="type">ulong i = class="num">0; i < eigenvalues.Size(); i++) { if (eigenvalues[i] > class="num">1.e-8) { sum += eigenvalues[i]; if (!cumulate.Push(sum)) { Print(__FUNCTION__, " error adding element ", GetLastError()); class="kw">return matrix::Zeros(class="num">1, class="num">1); } } } if (!cumeigenvalues.Assign(cumulate)) { Print(__FUNCTION__, " vector assignment error ", GetLastError()); class="kw">return matrix::Zeros(class="num">1, class="num">1); } cumeigenvalues /= cumeigenvalues[cumeigenvalues.Size() - class="num">1]; cumeigenvalues *= class="num">100.0; matrix structmat = eigenvectors; for (class="type">ulong i = class="num">0; i < structmat.Cols(); i++) { if (!structmat.Col(eigenvectors.Col(i) * sqrt(eigenvalues[i] >= class="num">0.0 ? eigenvalues[i] : class="num">0.0), i)) { Print(__FUNCTION__, "error ", GetLastError()); class="kw">return matrix::Zeros(class="num">1, class="num">1); } } if (!structmat.Clip(-class="num">1.0, class="num">1.0)) { Print(__FUNCTION__, "error ", GetLastError());