基于主成分的特征选择与降维·进阶篇
📉

基于主成分的特征选择与降维·进阶篇

(2/3)· PCA 把高度相关变量的贡献均匀稀释,根源分析看不清是谁在动;这篇进阶篇给出解法

案例拆解 第 2/3 篇
做多因子模型时,不少人直接把 PCA 出来的主成分丢进训练集,却没意识到一组高相关指标会被揉成一团,单个变量的真实影响被抹平。想做特征选择或归因,这种均匀稀释会让你误判信号来源。FSCA 用逐步挑选的思路,把最具解释力的变量先拎出来。

「主成分投影的逐行实现」

把标准化后的行情矩阵投影到特征向量空间,核心就是上面这段 compute_principal_components 函数。它按行遍历样本、按列遍历主成分数,用内积完成降维变换。 注意第 12 行除以 sqrt(m_eigvalues[j]) 这一步:这是把载荷向量还原成单位方差方向,漏掉这步会让波动率大的成分在外汇小时线回测里被过度放大。 m_num_comps 控制输出列数。实盘跑 EURUSD 的 H1 数据、取前 3 个主成分时,out 矩阵通常是 样本数×3 的维度,MT5 策略测试器里可直接用 MatrixPrint dump 出来核对。 外汇与贵金属杠杆高、滑点随机,这套 PCA 降维只解决共线特征压缩,信号方向仍带概率偏差,开 MT5 验证前先想清楚样本窗口长度。

MQL5 / C++
matrix compute_principal_components(class="type">void) {
  matrix out(m_data.Rows(), class="type">ulong(m_num_comps));
  vector drow, eigcol, nv;
  class="type">class="kw">double sum;
  for (class="type">ulong i = class="num">0; i < m_data.Rows(); i++) {
    drow = m_data.Row(i);
    for (class="type">ulong j = class="num">0; j < m_num_comps; j++) {
      sum = class="num">0.0;
      for (class="type">ulong k = class="num">0; k < m_data.Cols(); k++) {
        sum += drow[k] * m_eigvectors[k][j] / sqrt(m_eigvalues[j]);
      }
      out[i][j] = sum;
    }
  }
  class="kw">return out;
}

用可解释方差挑最优变量

FSCA 做前向选择时,每一步都要从候选集里挑一个变量加进已选子集。原论文给的评分公式看着绕,但作者证明了:竞争变量按评分排出来的名次,和按可解释方差排出来的名次完全一致。也就是说,分最高的那个变量,加进去之后可解释方差也最大,直接用方差逻辑挑就行。 代码里的 compute_criterion() 就是算这个评分的。它吃四个输入:相关矩阵 covar、已选变量下标数组 keptcols、已选数量 nkept、以及正在试的变量下标 trial_col。 函数先把已选变量和试用变量拼成扩展矩阵 mt,求逆得到 mtinv,再拿所有原始变量和已选部分的协方差做加权累加,最后吐出 crit。crit 越高,说明这个新变量带进来的增量信息越多,模型性能可能越好;越低则倾向于是冗余列。 在 MT5 里把这段直接塞进你的特征选择循环,每轮对候选列调一次 compute_criterion(),取返回值最大的下标入栈,就能复刻论文里的逐步筛选过程。外汇与贵金属数据噪声大、 regime 切换频繁,高 crit 变量也仅代表样本内解释力偏强,实盘前务必做 walk-forward 验证。

MQL5 / C++
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//|  calculates the criterion for assessing a component                |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">class="kw">double compute_criterion(matrix &covar, class="type">ulong &keptcols[], class="type">ulong nkept, class="type">ulong trial_col) {
    class="type">ulong i, j, k, irow, new_kept;
    class="type">class="kw">double sum, crit, dtemp;
    new_kept = nkept + class="num">1;                      class=class="str">"cmt">// 扩展后矩阵维度 = 已选数+class="num">1
    matrix mt(new_kept, new_kept);             class=class="str">"cmt">// 建扩展相关矩阵
    for (i = class="num">0; i < new_kept; i++) {
        if (i < nkept)
            irow = keptcols[i];                class=class="str">"cmt">// 前 nkept 行取已选变量下标
        else
            irow = trial_col;                  class=class="str">"cmt">// 最后一行取试用变量下标
        for (j = class="num">0; j < nkept; j++)
            mt[i][j] = covar[irow][keptcols[j]]; class=class="str">"cmt">// 填已选变量间相关
        mt[i][nkept] = covar[irow][trial_col];   class=class="str">"cmt">// 填与试用变量相关
    }
    matrix mtinv = mt.Inv();                   class=class="str">"cmt">// 求扩展矩阵逆
    vector vec(new_kept);
    crit = class="num">0.0;
    for (j = class="num">0; j < m_preds; j++) {            class=class="str">"cmt">// 遍历所有原始预测列
        for (i = class="num">0; i < nkept; i++)
            vec[i] = covar[j][keptcols[i]];    class=class="str">"cmt">// 装该列与已选变量协方差
        vec[nkept] = covar[j][trial_col];      class=class="str">"cmt">// 装与试用变量协方差
        sum = class="num">0.0;
        for (i = class="num">0; i < new_kept; i++)
            sum += vec[i] * vec[i] * mtinv[i][i]; class=class="str">"cmt">// 对角项加权平方累加
        crit += sum;
        sum = class="num">0.0;
        for (i = class="num">1; i < new_kept; i++) {
            dtemp = vec[i];
            for (k = class="num">0; k < i; k++)
                sum += dtemp * vec[k] * mtinv[i][k]; class=class="str">"cmt">// 非对角项交叉累加
        }
        crit += class="num">2.0 * sum;                     class=class="str">"cmt">// 交叉项乘2补对称部分
    }
    class="kw">return crit;                               class=class="str">"cmt">// 返回该试用变量评分
}

◍ 反向精炼怎么替掉冗余因子

反向精炼是前向选择的倒序玩法:先有一组已选变量,再逐个试删,看删掉谁对评估标准冲击最小。冲击低于阈值的就真删,循环到不能再删为止;只要发生过替换,例程返回 1,否则返回 0。 核心在 substvar() 这个替换例程。它临时把 old_col 位置换成 new_col,从相关矩阵里抠出已选变量对应的子矩阵,求逆后用逆矩阵加权的协方差累加出 crit。crit 越高,说明这次替换越可能改善模型;越低则越可能无益。 下面这段是 MT5 里可直接编译跑的例程。外层 backward_refinement() 遍历已选列,对内层未被选中的列调用 substvar() 找最优替补;一旦 best_col 有效就原位替换并标记 refined=1。注意 m_preds 是外部定义的候选因子总数,漏了会编译报错。 别把 crit 高低当圣旨:相关矩阵来自历史样本,外汇与贵金属价格结构会切换,替换出的组合在高波动段可能失效,实盘前请用近期数据重算。

MQL5 / C++
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| backward refinement routine                                     |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">ulong backward_refinement(matrix &covar, class="type">ulong &kept_columns[], class="type">ulong nkept, class="type">class="kw">double &best_crit) {
   class="type">ulong i, old_col, new_col, best_col, refined;
   class="type">class="kw">double crit;
   best_crit = substvar(covar, kept_columns, nkept, class="num">0, kept_columns[class="num">0]);
   refined = class="num">0;
   for (old_col = class="num">0; old_col < nkept; old_col++) {
      if (old_col == nkept - class="num">1 && !refined)
         break;
      best_col = ULONG_MAX;
      for (new_col = class="num">0; new_col < m_preds; new_col++) {
         for (i = class="num">0; i < nkept; i++) {
            if (new_col == kept_columns[i])
               break;
         }
         if (i < nkept)
            class="kw">continue;
         crit = substvar(covar, kept_columns, nkept, old_col, new_col);
         if (crit > best_crit) {
            best_crit = crit;
            best_col = new_col;
         }
      }
      if (best_col != ULONG_MAX && best_col >= class="num">0) {
         class=class="str">"cmt">// Print(__FUNCTION__,"  Replaced predictor at column ",kept_columns[old_col], " with ",best_col," to get criterion = ", best_crit);
         kept_columns[old_col] = best_col;
         refined = class="num">1;
      }
   }
   class="kw">return refined;
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| variable substitution routine                                    |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">class="kw">double substvar(matrix &covar, class="type">ulong &keptcols[], class="type">ulong nkept, class="type">ulong old_col, class="type">ulong new_col) {
   class="type">ulong i, j, k, irow, saved_col;
   class="type">class="kw">double sum, crit, dtemp;
   matrix mt(nkept, nkept);
   saved_col = keptcols[old_col];
   keptcols[old_col] = new_col;
   for (i = class="num">0; i < nkept; i++) {
      irow = keptcols[i];

「协方差矩阵求逆后的临界值拼装」

这段逻辑接在保留列筛选之后,核心是把降维后的协方差子块求逆,再用它给每个候选预测变量算马氏距离式的 crit 值。mt 是从全协方差里抠出的 nkept×nkept 子矩阵,mtinv 就是它的逆;若子矩阵奇异,Inv() 会返回空矩阵,后面乘加直接爆 NaN,所以前面保留列去相关那步不能省。 vec 每次装一个预测变量与保留列之间的协方差向量,先跑对角线项 sum += vec[i]*vec[i]*mtinv[i][i],再补上非对角交叉项 2*sum,两者相加才是该变量的完整贡献。m_preds 个变量全扫完,crit 累加值越大,说明这个新变量带来的多重共线性残余越重,越该被踢出。 末尾 keptcols[old_col] = saved_col 是把试探时占位的列号还原,保证函数纯计算、不改全局状态。你在 MT5 里跑这套,若 crit 突然跳到 1e9 量级,先查 mt.Inv() 是否返回了有效矩阵,而不是怀疑数据。

MQL5 / C++
for (j = class="num">0; j < nkept; j++) {
      mt[i][j] = covar[irow][keptcols[j]];
   }
   }
   matrix mtinv = mt.Inv();
   vector vec(nkept);
   crit = class="num">0.0;
   for (j = class="num">0; j < m_preds; j++) {
      for (i = class="num">0; i < nkept; i++) {
         vec[i] = covar[j][keptcols[i]];
      }
      sum = class="num">0.0;
      for (i = class="num">0; i < nkept; i++) {
         sum += vec[i] * vec[i] * mtinv[i][i];
      }
      crit += sum;
      sum = class="num">0.0;
      for (i = class="num">1; i < nkept; i++) {
         dtemp = vec[i];
         for (k = class="num">0; k < i; k++) {
            sum += dtemp * vec[k] * mtinv[i][k];
         }
      }
      crit += class="num">2.0 * sum;
   }
   keptcols[old_col] = saved_col;
   class="kw">return crit;
}

用格拉姆-施密特保住变量排序的同时去冗余

只做前向选择而不回砍变量,会得到一条从强到弱的影响力链条:排最前的变量解释力最大,往后逐级衰减。这条顺序在实盘建模里很有用,但原始值之间往往互相关,直接喂给训练器会引入多重共线,拖慢收敛还让贡献难以归因。 把已选变量改成一组彼此不相关的线性组合,能同时拿到「顺序不变」和「内部零冗余」两个好处。格拉姆-施密特正交化就是干这个的:第一个成分取首个变量的缩放版,后续每个成分都减去它在已有成分上的投影,再做单位长度标准化,最后统一缩到单位标准差。这样新矩阵每列互相垂直,原重要性次序却不乱。 下面这段 MQL5 函数接收输入矩阵、返回正交化后的矩阵。它逐列处理,把当前列向已正交化的子空间投影后扣掉,再归一;遇到零长度向量或列写入失败就打印错误并返回空矩阵,避免后续计算炸掉。 别把正态当圣经:正交化只解决列间相关,不保证新成分符合任何分布假设,外汇与贵金属样本里尾部依赖仍可能让模型在外推时失真,属高风险场景,上线前用 MT5 历史数据复跑确认。

MQL5 / C++
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//|  Gram Schmidt routine                                        |
class=class="str">"cmt">//+------------------------------------------------------------------+
matrix gram_schmidt(matrix &input_) {
    class="type">ulong irow, icol, inner;
    class="type">class="kw">double dtemp, sum;
    class="type">ulong nrows = input_.Rows();
    class="type">ulong ncols = input_.Cols();
    matrix output = input_;
    sum = class="num">0.0;
    vector colsum = output.Col(class="num">0);
    colsum = MathPow(colsum, class="num">2.0);
    sum = colsum.Sum();
    sum = sqrt(sum);
    if (sum == class="num">0.0) {
        Print(__FUNCTION__, " sum == class="num">0.0 ");
        class="kw">return matrix::Zeros(class="num">0, class="num">0);
    }
    if (!output.Col(output.Col(class="num">0) / sum, class="num">0)) {
        Print(__FUNCTION__, " failed column insertion ", GetLastError());
        class="kw">return matrix::Zeros(class="num">0, class="num">0);
    }
    for (icol = class="num">1; icol < ncols; icol++) {
        for (inner = class="num">0; inner < icol; inner++) {
            sum = class="num">0.0;
            for (irow = class="num">0; irow < nrows; irow++)
                sum += (output[irow][icol] * output[irow][inner]);
            for (irow = class="num">0; irow < nrows; irow++)
                output[irow][icol] -= (sum * output[irow][inner]);
        }
        sum = class="num">0.0;
        for (irow = class="num">0; irow < nrows; irow++) {
            dtemp = output[irow][icol];
            sum += dtemp * dtemp;
        }
        sum = sqrt(sum);
        if (sum == class="num">0.0) {
            Print(__FUNCTION__, " sum == class="num">0.0 ");
            class="kw">return matrix::Zeros(class="num">0, class="num">0);
        }
        if (!output.Col(output.Col(icol) / sum, icol)) {
            Print(__FUNCTION__, " failed column insertion ", GetLastError());
            class="kw">return matrix::Zeros(class="num">0, class="num">0);

◍ 把多周期信号收口成一条输出

上面这段收尾代码把前面层层嵌套的循环结果压进一个返回值里,外层大括号闭合后函数才把 output 交还给调用方。 这种写法在 MT5 自定义指标里很常见:先把各周期条件算完,最后统一出口,避免中途多次 return 导致逻辑散落。 开 MT5 把这段接在你自己的信号函数末尾,改一下 output 的赋值权重,就能直观看到多周期共振信号是怎么被压缩成单值的。外汇与贵金属波动剧烈,这类信号仅作概率参考,实盘须控仓。

MQL5 / C++
      }
   }
   class="kw">return output;
}
把重复劳动交给小布
小布盯盘已内置多因子诊断面板,打开对应品种页即可看到哪些特征对当前波动的解释占比最高,你只需专注决策而非手算方差贡献。

常见问题

PCA 将高相关变量均匀投影到主成分,单个变量贡献被稀释;FSCA 按可解释方差贪婪挑选变量,保留最具信息量的原始特征,更适合选变量和根源分析。
目前小布盯盘提供的是多因子解释占比与相关性诊断,FSCA 的逐步选择逻辑可借助其导出的特征贡献排序自行复现,无需从头写矩阵运算。
初步前向选出变量后,递归反向精炼会剔除被后续变量取代作用的早期选择,降低冗余,让最终子集更紧凑。
每选入一个变量会对剩余空间做正交补投影,使已选成分互不相关,后续候选只在未解释方差子空间评估贡献。
若停止准则设得过松、选入变量多,在高噪声贵金属市场可能拟合样本特异波动;建议以增量方差阈值为停点,并知外汇贵金属属高风险。