数据科学与机器学习(第 09 部分):以 MQL5 平铺直叙 K-均值聚类·进阶篇
📊

数据科学与机器学习(第 09 部分):以 MQL5 平铺直叙 K-均值聚类·进阶篇

(2/3)· 很多交易者囤了海量 tick 却不会分组,本篇教你用平铺代码把相似走势拆成可读聚类

进阶 第 2/3 篇
把未标记的价格序列直接喂给指标,却从不想先按形态分群,是多数 EA 开发者的隐性浪费。没有聚类的预处理,后续模型往往在噪声里空转。先理清数据该归到哪一类,再谈预测。

◍ 用直线距离给 K 均值做初次分堆

直线距离就是两点各维度坐标差的绝对值之和,比欧氏距离少了开方,写起来轻、算起来快。在 MT5 里做 K 均值初版,我倾向直接用这个度量找质心到样本点的距离,少踩数值稳定坑。 构造函数只接一个聚类数参数,默认 3 个簇。你可以按品种波动结构改这个数,但矩阵维度要同步重排,后面所有距离存储都依赖它。 下面这段代码先建了一个 n 行 × m_clusters 列的矩阵 rect_distance,再双层循环把每个样本到每个初始质心的直线距离写进去。内层用 MathAbs 做绝对值累加,就是直线距离本身。 [CODE 段已保留在 code 字段] 从日志看,EURUSD M1 上 5 个样本对 3 个质心的距离矩阵是 [[0,5,9],[5,6,4],[12,7,9],[5,0,10],[10,5,9]]。每行最小值所在列就是该点归属——第 0 行归簇 0,第 3 行归簇 1,其余按最小数落位。 存聚类别用 3×n 的零值矩阵比 vector 数组或 CSV 省事:行数等于簇数,列数先拉到最大可能容量,水平行存各簇成员,引用传进函数后过滤尾部零值即可。 K 均值不用无限循环等质心不动,给个有限迭代更稳。简单数据集通常 5–10 次迭代收敛,上面例子 2 次就停了。外汇贵金属价格序列高风险,聚类结果只反映历史分布,实盘信号务必加过滤。

MQL5 / C++
CKMeans::CKMeans(class="type">int clusters=class="num">3)
{
   m_clusters = clusters;
}
   matrix rect_distance = {};  class=class="str">"cmt">//matrix to store rectilinear distances
   rect_distance.Reshape(n,m_clusters);    
   vector v_matrix = {}, v_centroid = {};
   class="type">class="kw">double output = class="num">0;
   
   for (class="type">class="kw">ulong i=class="num">0; i<rect_distance.Rows(); i++)
   for (class="type">class="kw">ulong j=class="num">0; j<rect_distance.Cols(); j++)
      {
         v_matrix = Matrix.Row(i); 
   v_centroid = InitialCentroids.Row(j);         
         ZeroMemory(output);         
         for (class="type">class="kw">ulong k=class="num">0; k<v_matrix.Size(); k++)
            output += MathAbs(v_matrix[k] - v_centroid[k]); class=class="str">"cmt">//Rectilinear distance         
         rect_distance[i][j] = output; 
      }        
   Print("Rectilinear distance matrix\n",rect_distance);
CS       class="num">0      class="num">15:class="num">17:class="num">52.136   K-means test(EURUSD,M1)        Rectilinear distance matrix
CS       class="num">0      class="num">15:class="num">17:class="num">52.136   K-means test(EURUSD,M1)        [[class="num">0,class="num">5,class="num">9]
CS       class="num">0      class="num">15:class="num">17:class="num">52.136   K-means test(EURUSD,M1)         [class="num">5,class="num">6,class="num">4]
CS       class="num">0      class="num">15:class="num">17:class="num">52.136   K-means test(EURUSD,M1)         [class="num">12,class="num">7,class="num">9]
CS       class="num">0      class="num">15:class="num">17:class="num">52.136   K-means test(EURUSD,M1)         [class="num">5,class="num">0,class="num">10]
CS       class="num">0      class="num">15:class="num">17:class="num">52.136   K-means test(EURUSD,M1)         [class="num">10,class="num">5,class="num">9]

「把距离矩阵映射到簇编号」

K-means 跑完距离计算后,真正落地的动作是给每一行样本指派簇。上面日志里 EURUSD M1 的三次初始打印 [10,5,7]、[9,10,0]、[3,2,10] 是各样本到三个候选质心的距离向量,下一步就是取最小值下标。 核心循环里用 Row(i) 抽出第 i 个样本的距离向量,ArgMin() 直接返回最小距离所在的列索引,也就是簇号。日志中 Assigned clusters 打出 [0,2,1,1,1,1,2,1],说明 8 个样本被分进了 0、1、2 三个簇,其中簇 1 占了 5 个,分布并不均衡。 后面 clustered Matrix 的两行 [[2,10,0,...]、[2,5,1,2,0,...] 是每个簇内样本特征计数的展开,前 24 列里大量 0 说明多数维度在 M1 样本上无激活。外汇与贵金属这类高噪声品种用 K-means 做形态聚类,簇间重叠概率偏高,实盘前务必在 MT5 策略测试器用自有样本重跑验证。

MQL5 / C++
class=class="str">"cmt">//---  Assigning the Clusters
   matrix cluster_cent = {}; class=class="str">"cmt">//cluster centroids
   class="type">class="kw">ulong cluster = class="num">0;   
    for (class="type">class="kw">ulong i=class="num">0; i<rect_distance.Rows(); i++)
     {
        v_row = rect_distance.Row(i);
        cluster = v_row.ArgMin();              
        cluster_assign[i] = (class="type">uint)cluster;
     }
   Print("Assigned clusters\n",cluster_assign);

EURUSD_M1 上的 K-means 初始化与质心打印

在 EURUSD 的 M1 周期上跑 K-means 测试,日志里先吐出一轮聚类标签序列:[8,4,5,8,7,5,6,4,4,9,0,0,…],前 10 个样本被分到 0~9 号簇,后面 14 个全是 0 号簇,说明初始随机质心把多数样本吸到了同一个中心,小样本下容易塌缩。 核心函数 KMeansClustering 接收常量矩阵 Matrix 与输出引用 clustered_matrix,先用 ZeroMemory(rand_v) 清掉上轮残留,再按 cluster_cent.Cols() 遍历,把每一列的均值写进 x_y_z 向量作为初始质心分量,最后用 InitialCentroids.Row(x_y_z, i) 落盘。 第二轮日志打出 New Centroids:[[2,10],[1.5,3.5],[6,6]],三个二维质心坐标已算出。带 iterations=10 默认参数的重载版本里,先取 m_cols=Matrix.Cols()、n=Matrix.Rows(),把 InitialCentroids 扩成 m_clusters×m_cols,cluster_assign 扩成 n 长度,clustered_matrix 扩成 m_clusters×m_clusters*n 并 Fill(NULL) 占位。 外汇与贵金属属高风险品种,M1 噪声极大,这种初始化结果只代表某次随机种子下的局部解,换种子可能倾向不同簇分布,务必在 MT5 里多跑几次比对。

MQL5 / C++
class="type">void CKMeans::KMeansClustering(class="kw">const matrix &Matrix, matrix &clustered_matrix)
{
   vector x_y_z = {class="num">0,class="num">0};
   ZeroMemory(rand_v);
   
   for (class="type">class="kw">ulong k=class="num">0; k<cluster_cent.Cols(); k++)
     {
       x_y_z.Resize(cluster_cent.Cols());
       rand_v = cluster_cent.Col(k);
       x_y_z[k] = rand_v.Mean();
     }
     
   InitialCentroids.Row(x_y_z, i);
   
   if (index >= n_each_cluster.Size()) class="kw">break;
   }  
    Print("New Centroids\n",InitialCentroids,"\nclustered Matrix\n",clustered_matrix);
}

class="type">void CKMeans::KMeansClustering(class="kw">const matrix &Matrix, matrix &clustered_matrix,class="type">int iterations = class="num">10)
{
   m_cols = Matrix.Cols();
   n = Matrix.Rows(); class=class="str">"cmt">//number of elements | Matrix Rows
   
   InitialCentroids.Resize(m_clusters,m_cols);
   cluster_assign.Resize(n);
   
   clustered_matrix.Resize(m_clusters, m_clusters*n);
   clustered_matrix.Fill(NULL);
   
   vector cluster_comb_v = {};
   matrix cluster_comb_m = {};

◍ K均值聚类的初值设定与距离分配

用 K 均值给行情特征分组时,第一步是把初始质心从样本矩阵里按固定间隔抽出来,而不是随机撒点。下面这段代码用 i * m_clusters 的步长从 Matrix 取行写进 InitialCentroids,能保证质心覆盖样本空间的不同区段,降低后续迭代卡在局部极小的概率。 距离计算采用曼哈顿距离(rectilinear distance),即对每条样本向量与质心向量逐维取绝对差再求和。相比欧氏距离,曼哈顿距离对异常跳空价更不敏感,在外汇与贵金属这种常有瞬时毛刺的高风险品种上更稳。 分配阶段对每一行距离向量调 ArgMin(),直接拿到最近质心下标写进 cluster_assign。迭代次数由 iterations 控制,实盘验证时建议先设 10 次以内看收敛速度,再决定是否加量。 把 rect_distance 矩阵打印出来能看到 n 行 m_clusters 列的具体数值,哪列普遍偏小就说明样本更倾向归到那个簇。开 MT5 把这段接在你自己的特征矩阵后,就能直接观察聚类分布。

MQL5 / C++
vector rand_v = {};
for (class="type">class="kw">ulong i=class="num">0; i<m_clusters; i++)
  {
   rand_v = Matrix.Row(i * m_clusters);
   InitialCentroids.Row(rand_v,i);
  }
 Print("Initial Centroids matrix\n",InitialCentroids);
class=class="str">"cmt">//---

 vector v_row;
 vector n_each_cluster; class=class="str">"cmt">//Each cluster content
 matrix rect_distance = {};  class=class="str">"cmt">//matrix to store rectilinear distances
 rect_distance.Reshape(n,m_clusters);

 vector v_matrix = {}, v_centroid = {};
 class="type">class="kw">double output = class="num">0;
class=class="str">"cmt">//---
for (class="type">int iter=class="num">0; iter<iterations; iter++)
  {
   printf("\n<<<<< %d >>>>>\n",iter );

   for (class="type">class="kw">ulong i=class="num">0; i<rect_distance.Rows(); i++)
    for (class="type">class="kw">ulong j=class="num">0; j<rect_distance.Cols(); j++)
      {
        v_matrix = Matrix.Row(i);
 v_centroid = InitialCentroids.Row(j);

        ZeroMemory(output);
        for (class="type">class="kw">ulong k=class="num">0; k<v_matrix.Size(); k++)
           output += MathAbs(v_matrix[k] - v_centroid[k]); class=class="str">"cmt">//Rectilinear distance

        rect_distance[i][j] = output;  
      }

    Print("Rectilinear distance matrix\n",rect_distance);

class=class="str">"cmt">//--- Assigning the Clusters
 matrix cluster_cent = {}; class=class="str">"cmt">//cluster centroids
 class="type">class="kw">ulong cluster = class="num">0;  
 for (class="type">class="kw">ulong i=class="num">0; i<rect_distance.Rows(); i++)
    {
     v_row = rect_distance.Row(i);
     cluster = v_row.ArgMin();

     cluster_assign[i] = (class="type">uint)cluster;
    }
 Print("Assigned clusters\n",cluster_assign);
class=class="str">"cmt">//--- Combining the clusters 

   n_each_cluster.Resize(m_clusters);
   for (class="type">class="kw">ulong i=class="num">0, index =class="num">0, sum_count = class="num">0; i<cluster_assign.Size(); i++)
    {
     for (class="type">class="kw">ulong j=class="num">0, count = class="num">0; j<cluster_assign.Size(); j++)
      {
       class=class="str">"cmt">//printf("cluster_assign[%d] cluster_assign[%d]",i,j);
       if (cluster_assign[i] == cluster_assign[j])

「K均值聚类的簇扩展与质心重算」

这段逻辑出现在聚类迭代的尾部,负责把当前样本归并进新簇或跳过已处理项。当命中一个尚未成簇的样本时,count 自增,并把该样本在 n_each_cluster 里登记为第 count 个簇的成员数。 cluster_comb_m.Resize(count, m_cols) 先给组合矩阵扩出一行,再用 Row(Matrix.Row(j), count-1) 把原数据第 j 行搬进新簇末尾;cluster_cent 同样扩容,并把同一行写进质心矩阵——此时新质心直接等于样本自身,属于冷启动做法。 迭代收尾阶段,MatrixToVector 把簇矩阵压平,若已是最后一次(iter == iterations-1)就把结果写回 clustered_matrix。随后 index 前进,并对 cluster_cent 每列求均值生成 x_y_z 向量作为下一轮 InitialCentroids 的第 i 行;一旦 index 越界 n_each_cluster.Size() 立即 break。 在 MT5 里跑这套,能把 EURUSD 的 M15 特征向量按上述逻辑实时分簇,但外汇与贵金属波动受杠杆与跳空影响,聚类结果只反映历史分布,下一根 K 线归属倾向变化,属高风险验证。

MQL5 / C++
{
   count++;
   n_each_cluster[index] = (class="type">uint)count;

   cluster_comb_m.Resize(count, m_cols);
   cluster_comb_m.Row(Matrix.Row(j) , count-class="num">1);
   cluster_cent.Resize(count, m_cols);
   class=class="str">"cmt">// New centroids 
   cluster_cent.Row(Matrix.Row(j),count-class="num">1);
   sum_count++;
}
 else class="kw">continue;
}
class=class="str">"cmt">//---
 MatrixToVector(cluster_comb_m, cluster_comb_v);
 class=class="str">"cmt">// solving for new cluster and updtating the old ones

 if (iter == iterations-class="num">1)
clustered_matrix.Row(cluster_comb_v, index);
class=class="str">"cmt">//---
 index++;
class=class="str">"cmt">//---
 vector x_y_z = {class="num">0,class="num">0};
 ZeroMemory(rand_v);

 for (class="type">class="kw">ulong k=class="num">0; k<cluster_cent.Cols(); k++)
  {
   x_y_z.Resize(cluster_cent.Cols());
   rand_v = cluster_cent.Col(k);
   x_y_z[k] = rand_v.Mean();
  }
 InitialCentroids.Row(x_y_z, i);
 if (index >= n_each_cluster.Size())
class="kw">break;
 }

一次迭代后质心与簇分配的实际输出

在 EURUSD M1 品种上跑 K-means 测试,首次迭代收尾时终端会打印出新质心,同时输出距离矩阵与簇分配结果。下面这段是某次实测里 20:40:05.438 时刻的日志片段,采用矩形距离(曼哈顿距离)计算。 从日志看,初始 8 个样本被分成 3 簇,分配标签为 [0,2,1,1,1,1,2,1];迭代后质心从初始随机点收敛为 [[2,10],[1.5,3.5],[6,6]]。距离矩阵首行 [0,7,8] 表示第 0 个样本到三个新质心的矩形距离分别为 0、7、8,说明它已落在第 0 簇质心位置上。 把 Print 语句里的注释打开,就能在 MT5 终端里对照看 clustered_matrix 是否和分配标签对得上。外汇与贵金属市场高杠杆、跳空频繁,这类聚类结果只反映历史样本分布,对后续行情只有概率意义上的参考。

MQL5 / C++
Print("New Centroids\n",InitialCentroids);class=class="str">"cmt">//,"\nclustered Matrix\n",clustered_matrix);
} class=class="str">"cmt">//end of iterations
}
让小布替你跑这套
这些诊断小布盯盘的 AIGC 已内置,打开对应品种页即可看到聚类分组与质心漂移,把重复劳动交给小布,你专注决策。

常见问题

K-均值是无监督聚类,把 n 个观测划成 k 个群,每组归最近质心;k-最邻近是监督分类,下一篇会单独讲,靠已知标签判新样本归属。
可以,小布盯盘的 AIGC 模块对打开的品种页自动输出聚类分组与质心变化,省去手写 CKMeans 函数库的过程。
独占如 K-均值适合固定状态分群,层次适合探索嵌套结构;实盘品种状态数相对固定,倾向用独占更轻量。
可能,随机种子不同质心漂移,多次跑取稳定解或改用 k-means++ 初始化可降低波动概率。
不能当确定性信号,仅缩小观测空间;结合后续回归或规则,才可能提取概率倾向。