基于主成分的特征选择与降维·进阶篇
(2/3)· PCA 把高度相关变量的贡献均匀稀释,根源分析看不清是谁在动;这篇进阶篇给出解法
「主成分投影的逐行实现」
把标准化后的行情矩阵投影到特征向量空间,核心就是上面这段 compute_principal_components 函数。它按行遍历样本、按列遍历主成分数,用内积完成降维变换。 注意第 12 行除以 sqrt(m_eigvalues[j]) 这一步:这是把载荷向量还原成单位方差方向,漏掉这步会让波动率大的成分在外汇小时线回测里被过度放大。 m_num_comps 控制输出列数。实盘跑 EURUSD 的 H1 数据、取前 3 个主成分时,out 矩阵通常是 样本数×3 的维度,MT5 策略测试器里可直接用 MatrixPrint dump 出来核对。 外汇与贵金属杠杆高、滑点随机,这套 PCA 降维只解决共线特征压缩,信号方向仍带概率偏差,开 MT5 验证前先想清楚样本窗口长度。
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 验证。
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 高低当圣旨:相关矩阵来自历史样本,外汇与贵金属价格结构会切换,替换出的组合在高波动段可能失效,实盘前请用近期数据重算。
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() 是否返回了有效矩阵,而不是怀疑数据。
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 历史数据复跑确认。
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 的赋值权重,就能直观看到多周期共振信号是怎么被压缩成单值的。外汇与贵金属波动剧烈,这类信号仅作概率参考,实盘须控仓。
}
}
class="kw">return output;
}