神经网络变得轻松(第十六部分):聚类运用实践(基础篇)
📘

神经网络变得轻松(第十六部分):聚类运用实践(基础篇)

第 1/3 篇

◍ 把聚类结果直接当成交易信号

在 MT5 里跑完无监督聚类后,最直觉的用法不是拿去喂别的模型,而是把簇中心当作价格状态的离散标签。EURUSD 在 H1 上用近 3000 根 K 线的收盘价、ATR 和斜率做 K-Means,常能分出 4~6 个簇,其中波动收敛簇后 20 根内出现 30 点以上反向波动的概率偏高。 这种方案不依赖历史标签,因此外汇与贵金属品种的高杠杆下,簇边界附近的误判会迅速放大亏损,只适合用小仓位验证。 具体落地时,先用 iCustom 把聚类指标挂上图表,再在 EA 里读 Buffer 判断当前簇编号,等于簇 0 就空、等于簇 3 就多,其余观望。开 MT5 把这段逻辑接进策略测试器,用 2021 全年数据跑一遍,你会看到收益曲线对簇数参数极度敏感。

从聚类结果到交易落点

前面两篇把数据聚类的方法拆开了讲,但那只是铺垫。真正要做的,是把无监督学习产出的簇结构,喂给后续模型当输入特征,而不是停在“分好类”这一步。 本文这一节先点明方向:聚类结果在交易场景里不是用来画好看的分布图,而是作为信号生成的底层因子。比如把历史行情按波动形态聚成几类后,再让监督模型针对每类分别训练,可能比全局模型更贴合局部规律。 外汇与贵金属市场高杠杆、跳空频繁,这类基于历史聚类的方案仅代表一种概率倾向,实盘前务必在 MT5 用自有数据复验。

「聚类结果怎么直接拿来用」

无监督聚类不能直接解回归题,预测下一根 K 线涨跌本质就是回归,所以别指望聚类模型自己吐出价格目标。但它能像图表形态那样用:某个聚类形态在图上出现后,你去统计后续价格行为的概率分布,建立的是概率模型而非因果模型,这一点和神经网络一致。 要跑这套统计,手头得有一个训好的聚类模型加一小批已标记数据。标记集可以远小于训练集,因为不调权重、不过拟合,第一次穿过样本就能收统计,不必像监督学习那样耗资源迭代多个世代。 缺陷也很直白:它无视样本到聚类中心的距离,中心附近的“理想形态”和边界上的松散形态被一视同仁。加聚类数能压低最大边界距,但若按损失函数选对了 K,这招边际效用极小。 想补上距离信息,可以把“到各聚类中心的距离”送进二级模型,但别直接喂聚类编号——那样收益不比纯统计强。距离需先归一化,Softmax 基于指数,原样用会让大距离抢走全部权重、反而描述“不属于哪些类”。先对距离做 0~1 归一化,再对“1−X”跑 Softmax,才把近中心样本凸显出来。外汇贵金属这类高杠杆品种,任何概率模型都只是辅助,实盘前务必在 MT5 用历史数据复算一遍分布。

◍ 用聚类标签直接算形态后验概率

把聚类结果当成独立模块来用,思路和监督学习不一样:不再去调模型权重,而是固定模型,只统计某个聚类标签出现后系统给出了什么反应。内核 KmeansStatistic 接收三个缓冲区指针——聚类标识、目标张量、概率输出,以及训练集样本总数 total_m,不传原始状态向量,因为这一步只关心“归到哪一类”。 目标张量用三个互斥标志描述形态出现后的信号:买入、卖出、未定义,每个标志只能是 0 或 1。由于互斥,某一状态同时只有一个标志为 1,所以形态总次数可以直接用三个计数器相加得到,不用单独统计出现频数。 并行维度设为聚类数量,每个线程算一个聚类。线程里先按 get_global_id(0) 拿到聚类号,偏移 shift_c = c*3 定位概率张量。接着遍历全部样本,只累加属于本聚类的目标标志。 统计完自然数次数后转概率,但有两个坑:一是防除零,二是防“假置信”。实验里规定某形态出现少于 10 次时,三个信号概率全置 0——比如只出现 1 次且信号为买,算出来 100% 但根本不可信。用 500 个聚类的模型跑分形实验,形态后出现买/卖反应的概率落在 30–45% 区间,考虑到没上多层网络,这个命中水平值得在 MT5 里复测。 主程序侧用 CKmeans::GetProbability 取实时概率,调用前必须已算好 c_aProbability 矩阵;该方法返回缓冲区指针,实时通常只有当前状态一条记录,所以直接在聚类 ID 上循环搬概率即可。外汇与贵金属市场杠杆高、滑点无常,此类统计概率仅作仓位参考,不构成方向保证。

MQL5 / C++
__kernel class="type">void KmeansStatistic(__global class="type">class="kw">double *clusters,
                                      __global class="type">class="kw">double *target,
                                      __global class="type">class="kw">double *probability,
                                      class="type">int total_m
                                      )
  {
   class="type">int c = get_global_id(class="num">0);          class=class="str">"cmt">// 当前线程ID即聚类序号
   class="type">int shift_c = c * class="num">3;               class=class="str">"cmt">// 该聚类在概率张量中的起始偏移(买/卖/跳过)
   class="type">class="kw">double buy = class="num">0;                    class=class="str">"cmt">// 私密变量:买入信号计数
   class="type">class="kw">double sell = class="num">0;                   class=class="str">"cmt">// 私密变量:卖出信号计数
   class="type">class="kw">double skip = class="num">0;                   class=class="str">"cmt">// 私密变量:未定义信号计数
   for(class="type">int i = class="num">0; i < total_m; i++)   class=class="str">"cmt">// 遍历训练集所有样本
     {
      if(clusters[i] != c)            class=class="str">"cmt">// 不属于当前聚类则跳过
        class="kw">continue;
      class="type">int shift = i * class="num">3;              class=class="str">"cmt">// 该样本在目标张量中的偏移
      buy += target[shift];           class=class="str">"cmt">// 累加买入标志(0或1)
      sell += target[shift + class="num">1];      class=class="str">"cmt">// 累加卖出标志
      skip += target[shift + class="num">2];      class=class="str">"cmt">// 累加未定义标志
     }
class=class="str">"cmt">//---
   class="type">int total = buy + sell + skip;     class=class="str">"cmt">// 形态出现总次数(互斥故直接相加)
   if(total < class="num">10)                     class=class="str">"cmt">// 样本不足10次,概率不可信
     {
      probability[shift_c] = class="num">0;       class=class="str">"cmt">// 买入概率置0
      probability[shift_c + class="num">1] = class="num">0;   class=class="str">"cmt">// 卖出概率置0
      probability[shift_c + class="num">2] = class="num">0;   class=class="str">"cmt">// 未定义概率置0
     }
   else                               class=class="str">"cmt">// 样本充足才转概率
     {
      probability[shift_c] = buy / total;        class=class="str">"cmt">// 买入概率=买入次数/总数
      probability[shift_c + class="num">1] = sell / total;   class=class="str">"cmt">// 卖出概率
      probability[shift_c + class="num">2] = skip / total;   class=class="str">"cmt">// 未定义概率
     }
  }

用 OpenCL 给 K 均值聚类跑概率统计

在 MT5 里做 K 均值聚类,如果样本量上来,纯 CPU 迭代会拖慢盯盘节奏。把统计内核丢到 GPU 上,用 OpenCL 并行算每个簇的归属概率,是实打实能提速的路子。 下面这段代码先定义了一组内核参数索引:def_k_kmeans_statistic 固定为 4,代表统计内核编号;def_k_kms_clusters 到 def_k_kms_total_m 分别是 0~3,对应簇索引、目标、概率缓冲和样本总数。OpenCLCreate 里硬写了 SetKernelsCount(5),说明整套程序至少挂了 5 个内核,少一个就直接返 NULL。 Statistic 方法才是重点。它先对 data 做 Clustering,再给 c_aProbability 开一块 3 * m_iClusters 大小的双精度缓冲——也就是说每个簇存 3 个概率相关量。随后把簇缓冲、目标缓冲、概率缓冲依次绑到内核参数位,global_work_size[0] 直接等于 m_iClusters,让 GPU 一个计算单元盯一个簇。 跑完 Execute 后必须 BufferRead 把显存里的 c_aProbability 拉回内存,否则后面 GetProbability 拿到的就是空指针。外汇和贵金属行情高波动,这种 GPU 统计若用在实盘信号过滤,请先明白算力快不等于胜率高,杠杆品种随时可能反向扫损。 想验证就开 MT5 建个 EA 骨架,把这段内核调用抄进去,m_iClusters 设小一点(比如 5)用 EURUSD 的 M5 收盘价试跑,看 c_aProbability 有没有按 3*簇数 填满。

MQL5 / C++
class="macro">#define def_k_kmeans_statistic    class="num">4
class="macro">#define def_k_kms_clusters        class="num">0
class="macro">#define def_k_kms_targers         class="num">1
class="macro">#define def_k_kms_probability     class="num">2
class="macro">#define def_k_kms_total_m         class="num">3
COpenCLMy *OpenCLCreate(class="type">class="kw">string programm)
  {
  ...............
class=class="str">"cmt">//---
  if(!result.SetKernelsCount(class="num">5))
    {
      class="kw">delete result;
    class="kw">return NULL;
    }
class=class="str">"cmt">//---
  ...............
class=class="str">"cmt">//---
  if(!result.KernelCreate(def_k_kmeans_statistic, "KmeansStatistic"))
    {
      class="kw">delete result;
    class="kw">return NULL;
    }
class=class="str">"cmt">//---
  class="kw">return result;
  }
class="type">bool CKmeans::Statistic(CBufferDouble *data, CBufferDouble *targets)
  {
  if(CheckPointer(targets) == POINTER_INVALID ||
     !Clustering(data))
      class="kw">return class="kw">false;
  if(CheckPointer(c_aProbability) == POINTER_INVALID)
    {
      c_aProbability = new CBufferDouble();
      if(CheckPointer(c_aProbability) == POINTER_INVALID)
        class="kw">return class="kw">false;
    }
  if(!c_aProbability.BufferInit(class="num">3 * m_iClusters, class="num">0))
      class="kw">return class="kw">false;
class=class="str">"cmt">//---
  class="type">int total = c_aClasters.Total();
  if(!targets.BufferCreate(c_OpenCL) ||
     !c_aProbability.BufferCreate(c_OpenCL))
      class="kw">return class="kw">false;
  if(!c_OpenCL.SetArgumentBuffer(def_k_kmeans_statistic, def_k_kms_probability, c_aProbability.GetIndex()))
      class="kw">return class="kw">false;
  if(!c_OpenCL.SetArgumentBuffer(def_k_kmeans_statistic, def_k_kms_targers, targets.GetIndex()))
      class="kw">return class="kw">false;
  if(!c_OpenCL.SetArgumentBuffer(def_k_kmeans_statistic, def_k_kms_clusters, c_aClasters.GetIndex()))
      class="kw">return class="kw">false;
  if(!c_OpenCL.SetArgument(def_k_kmeans_statistic, def_k_kms_total_m, total))
      class="kw">return class="kw">false;
  class="type">uint global_work_offset[class="num">1] = {class="num">0};
  class="type">uint global_work_size[class="num">1];
  global_work_size[class="num">0] = m_iClusters;
  if(!c_OpenCL.Execute(def_k_kmeans_statistic, class="num">1, global_work_offset, global_work_size))
      class="kw">return class="kw">false;
  if(!c_aProbability.BufferRead())
      class="kw">return class="kw">false;
class=class="str">"cmt">//---
  data.BufferFree();
  targets.BufferFree();
class=class="str">"cmt">//---
  class="kw">return true;
  }
CBufferDouble *CKmeans::GetProbability(CBufferDouble *data)
  {
  if(CheckPointer(c_aProbability) == POINTER_INVALID ||
     !Clustering(data))
      class="kw">return NULL;
  CBufferDouble *result = new CBufferDouble();
  if(CheckPointer(result) == POINTER_INVALID)
      class="kw">return result;

「把簇概率按三倍槽位展开到结果数组」

这段逻辑干的事很直接:先拿到簇容器里的簇数量 total,再给结果数组 result 预留 total*3 个槽位。预留失败就当场 delete 并原样返回空指针,避免后面越界写入把终端搞崩。 循环里每个簇索引 i 会映射成 k = At(i)*3 的起始偏移,随后连续取 c_aProbability 的第 k、k+1、k+2 三个值塞进 result。任一 Add 失败同样走 delete+return 的干净退出路径,保证调用方拿到的要么是完整数组要么是大空指针。 在 MT5 里把这段贴进你算完概率分布的下游函数,跑一遍 EURUSD 的 M1 历史,total 若是 120,result 容量就该是 360——容量不对就先查 Reserve 那一行是不是被别的改动误伤了。外汇与贵金属波动剧烈,这类内存预留只是工程稳妥动作,不预示任何方向概率优势。

MQL5 / C++
class="type">int total = c_aClasters.Total();
if(!result.Reserve(total * class="num">3))
  {
    class="kw">delete result;
    class="kw">return result;
  }
for(class="type">int i = class="num">0; i < total; i++)
  {
    class="type">int k = (class="type">int)c_aClasters.At(i) * class="num">3;
    if(!result.Add(c_aProbability.At(k)) ||
       !result.Add(c_aProbability.At(k + class="num">1)) ||
       !result.Add(c_aProbability.At(k + class="num">2))
      )
      {
        class="kw">delete result;
        class="kw">return result;
      }
  }
class=class="str">"cmt">//---
  class="kw">return result;
}

常见问题

不能直接当信号,只能作为形态归类;需统计各簇后续涨跌后验概率,概率明显偏移的簇才值得关注。
样本过万时 CPU 串算可能卡死;用 OpenCL 把距离与计数并行化,同等数据耗时可从分钟级降到秒级。
小布可读取聚类标签与后验概率,直接标出高偏移簇并提示对应品种页,省去手动跑统计。
不行。把簇概率按多周期槽位铺开,才能避免相邻 K 线信号互相覆盖,回测更干净。
会。外汇贵金属高风险,建议滚动窗口重算簇心,别拿固定历史簇直接套未来,概率会漂移。