神经网络变得轻松(第十五部分):利用 MQL5 进行数据聚类·进阶篇
📘

神经网络变得轻松(第十五部分):利用 MQL5 进行数据聚类·进阶篇

第 2/3 篇

把 OpenCL 环境接进聚类类的准备工作

在 MT5 里写 K 均值聚类,第一步不是算距离,而是把运行环境搭好。主程序端要新建 CKmeans 类并存入 kmeans.mqh,但在此之前得先解决数据搬运和 OpenCL 内核的接入。我们直接复用之前文章里写过的 CBufferDouble 来向 OpenCL 传数据,不重写只 include 现成库,省事也避免引入新 bug。 资源文件和常量得先定死。用 #resource 把 unsupervised.cl 挂成字符串资源,再定义一组带 0x7901 标识前缀的宏,区分类标识、内核编号和参数槽位。内核在 OpenCL 程序里是连续编号,但参数在每个内核内单独编号,所以宏按内核分组写,读代码时一眼能看出某个 def_k_km* 属于哪个计算阶段。 为了以后聚类可能被别的复杂模型调用,我们没有把 COpenCLMy 的初始化硬塞进 CKmeans 构造函数,而是单独抽了 OpenCLCreate 函数。它接收 cl 程序文本、返回对象指针:先 new 一个实例,CheckPointer 无效就直接返 NULL;Initialize 失败或 SetKernelsCount(4) 失败都 delete 掉再返 NULL。 内核创建逐个来,示例里只展示了 def_k_kmeans_distance 对应 KmeansCulcDistance 这一个,其余 3 个同法炮制,全成功才返回指针。准备好这些,才算能动手写真正的聚类逻辑。 下面这段是准备阶段的核心代码,注意宏名里的数字就是内核/参数索引,改 OpenCL 程序时两边必须对齐。

MQL5 / C++
class="macro">#include "..\NeuroNet_DNG\NeuroNet.mqh"
class="macro">#resource "unsupervised.cl" as class="type">class="kw">string cl_unsupervised
class="macro">#define defUnsupervisedKmeans      0x7901
class="macro">#define def_k_kmeans_distance      class="num">0
class="macro">#define def_k_kmd_data             class="num">0
class="macro">#define def_k_kmd_means            class="num">1
class="macro">#define def_k_kmd_distance         class="num">2
class="macro">#define def_k_kmd_vector_size      class="num">3
class="macro">#define def_k_kmeans_clustering    class="num">1
class="macro">#define def_k_kmc_distance         class="num">0
class="macro">#define def_k_kmc_clusters         class="num">1
class="macro">#define def_k_kmc_flags            class="num">2
class="macro">#define def_k_kmc_total_k          class="num">3
class="macro">#define def_k_kmeans_updates       class="num">2
class="macro">#define def_k_kmu_data             class="num">0
class="macro">#define def_k_kmu_clusters         class="num">1
class="macro">#define def_k_kmu_means            class="num">2
class="macro">#define def_k_kmu_total_m          class="num">3
class="macro">#define def_k_kmeans_loss          class="num">3
class="macro">#define def_k_kml_data             class="num">0
class="macro">#define def_k_kml_clusters         class="num">1
class="macro">#define def_k_kml_means            class="num">2
class="macro">#define def_k_kml_loss             class="num">3
class="macro">#define def_k_kml_vector_size      class="num">4
COpenCLMy *OpenCLCreate(class="type">class="kw">string programm)
  {
   COpenCL *result = new COpenCLMy();
   if(CheckPointer(result) == POINTER_INVALID)
      class="kw">return NULL;
   if(!result.Initialize(programm, true))
     {
      class="kw">delete result;
      class="kw">return NULL;
     }
   if(!result.SetKernelsCount(class="num">4))
     {
      class="kw">delete result;
      class="kw">return NULL;
     }
class=class="str">"cmt">//---
   if(!result.KernelCreate(def_k_kmeans_distance, "KmeansCulcDistance"))
     {
      class="kw">delete result;
      class="kw">return NULL;
     }
class=class="str">"cmt">//---
...........
class=class="str">"cmt">//---
   class="kw">return result;
  }

◍ 给 k-均值写一个能跑 OpenCL 的 C++ 类

做 MT5 上的聚类模型,第一步是把 k-均值封装成一个独立的 CKmeans 类。超参数只留两个:聚类数 m_iClusters 和单条系统状态描述向量长度 m_iVectorSize;训练品质用 m_dLoss 存损失函数,m_bTrained 标志区分模型有没有训过。所有变量放 protected,方法全公开,方便后面直接调。 OpenCL 交互靠五个缓冲区:c_aDistance、c_aMeans、c_aClasters、c_aFlags、c_aLoss,名字必须和 .cl 程序里的内核参数对上,否则丢数据没提示。构造函数里默认聚类数 2、向量长 1、损失 -1、未训练,并给均值矩阵开缓冲区清零。 Study 方法负责训练。入口先校验样本指针和 OpenCL 环境,再卡三道关:样本元素数必须是向量长的整数倍;样本状态数至少是聚类数的 10 倍;初始化标志为真时才用训练集里随机抽的向量填中心矩阵,且用 false 标志数组挡住重复初始化同一状态。 训练主循环三维内核依次叫:算距离、按最小距离派簇、更新中心。第一和第三个内核走二维任务空间,第二内核一维;维度大小用 global_work_size 和 global_work_offset 静态数组控,第一维是样本状态数,第二维是聚类数。循环里只盯 c_aFlags 最大值——全 0 代表没有状态换簇,就退出并读聚类 ID。 Clustering 方法是 Study 的阉割版,只调前两个内核一次,用来给新数据打标签。getloss 则先聚类再算平均偏差:偏差缓冲区在环境内存里累加后除以样本数,均值和簇 ID 缓冲区因为训完没删所以直接复用,省一次拷贝。Save / Load 把模型落盘和回读,避免每次重训烧资源。 下面这段是构造和析构的骨架,注意缓冲区只用 CheckPointer 判动态才 delete,c_OpenCL 初始留 NULL 由外部注入。

MQL5 / C++
class="type">void CKmeans::CKmeans(class="type">void)   :  m_iClusters(class="num">2),
                      m_iVectorSize(class="num">1),
                      m_dLoss(-class="num">1),
                      m_bTrained(class="kw">false)
  {
  c_aMeans = new CBufferDouble();
  if(CheckPointer(c_aMeans) != POINTER_INVALID)
      c_aMeans.BufferInit(m_iClusters * m_iVectorSize, class="num">0);
  c_OpenCL = NULL;
  }
class="type">void CKmeans::~CKmeans(class="type">void)
  {
  if(CheckPointer(c_aMeans) == POINTER_DYNAMIC)
      class="kw">delete c_aMeans;
  if(CheckPointer(c_aDistance) == POINTER_DYNAMIC)
      class="kw">delete c_aDistance;
  if(CheckPointer(c_aClasters) == POINTER_DYNAMIC)
      class="kw">delete c_aClasters;

「K均值初始化里的样本量硬约束」

把 KMeans 搬到 MT5 的 OpenCL 封装里,Init 先卡三道门槛:context 指针无效、簇数小于 2、向量维度小于 1,任一不满足直接返回 false。簇中心缓冲 c_aMeans 用 CBufferDouble 动态申请,按 m_iClusters * m_iVectorSize 初始化为 0,这一步决定了后面所有距离计算的布局。 Study 阶段有个容易被忽略的死线:样本行数 rows 必须超过 10 * m_iClusters,否则返回 false。也就是说 5 个簇至少要有 50 行有效向量,否则聚类没有统计意义,模型直接拒训。 初始质心用 MathRand() 平方归一化挑行,避免线性随机的聚集偏差;挑过的行打 flags 标记,撞到已选行就 i-- 重抽。这段逻辑在 EURUSD 的 H1 特征矩阵上跑,若向量维度是 6、簇数设 8,缓冲长度就是 48,样本少于 80 行时 Study 必然失败。 外汇与贵金属杠杆高、滑点无常,这类聚类特征用于辅助判断形态分布,不代表信号必然有效,实盘前请在策略测试器用历史数据验证样本量阈值。

MQL5 / C++
  if(CheckPointer(c_aFlags) == POINTER_DYNAMIC)
      class="kw">delete c_aFlags;
  if(CheckPointer(c_aLoss) == POINTER_DYNAMIC)
      class="kw">delete c_aLoss;
  }
class="type">bool CKmeans::Init(COpenCLMy *context, class="type">int clusters, class="type">int vector_size)
  {
   if(CheckPointer(context) == POINTER_INVALID || clusters < class="num">2 || vector_size < class="num">1)
      class="kw">return class="kw">false;
class=class="str">"cmt">//---
   c_OpenCL = context;
   m_iClusters = clusters;
   m_iVectorSize = vector_size;
   if(CheckPointer(c_aMeans) == POINTER_INVALID)
     {
       c_aMeans = new CBufferDouble();
       if(CheckPointer(c_aMeans) == POINTER_INVALID)
         class="kw">return class="kw">false;
     }
   c_aMeans.BufferFree();
   if(!c_aMeans.BufferInit(m_iClusters * m_iVectorSize, class="num">0))
      class="kw">return class="kw">false;
   m_bTrained = class="kw">false;
   m_dLoss = -class="num">1;
class=class="str">"cmt">//---
   class="kw">return true;
  }
class="type">bool CKmeans::Study(CBufferDouble *data, class="type">bool init_means = true)
  {
   if(CheckPointer(data) == POINTER_INVALID || CheckPointer(c_OpenCL) == POINTER_INVALID)
      class="kw">return class="kw">false;
class=class="str">"cmt">//---
   class="type">int total = data.Total();
   if(total <= class="num">0 || m_iClusters < class="num">2 || (total % m_iVectorSize) != class="num">0)
      class="kw">return class="kw">false;
class=class="str">"cmt">//---
   class="type">int rows = total / m_iVectorSize;
   if(rows <= (class="num">10 * m_iClusters))
      class="kw">return class="kw">false;
   class="type">bool flags[];
   if(ArrayResize(flags, rows) <= class="num">0 || !ArrayInitialize(flags, class="kw">false))
      class="kw">return class="kw">false;
class=class="str">"cmt">//---
   for(class="type">int i = class="num">0; (i < m_iClusters && init_means); i++)
     {
       Comment(StringFormat("Cluster initialization %d of %d", i, m_iClusters));
       class="type">int row = (class="type">int)((class="type">class="kw">double)MathRand() * MathRand() / MathPow(class="num">32767, class="num">2) * (rows - class="num">1));
       if(flags[row])
         {
          i--;
          class="kw">continue;
         }
       class="type">int start = row * m_iVectorSize;
       class="type">int start_c = i * m_iVectorSize;
       for(class="type">int c = class="num">0; c < m_iVectorSize; c++)
         {
          if(!c_aMeans.Update(start_c + c, data.At(start + c)))
            class="kw">return class="kw">false;
         }
       flags[row] = true;
     }
   if(CheckPointer(c_aDistance) == POINTER_INVALID)
     {

GPU 上跑 K-Means 的缓冲区与内核绑定

在 MT5 里用 OpenCL 加速 K-Means,第一步是把三个核心缓冲拉开:距离矩阵 c_aDistance 按 rows*m_iClusters 初始化,簇标记 c_aClasters 和收敛标志 c_aFlags 各按 rows 初始化,初值全填 0。指针创建失败立刻 return false,这是避免 EA 在显存申请阶段静默崩掉的基本动作。 缓冲就绪后,调用 BufferCreate 把它们挂到 c_OpenCL 上下文;只要任意一个返回 false,整个聚类函数直接退出。注意 data、c_aMeans 也要同步创建,否则内核取不到输入。 进入 do 循环后,先给 kmeans_distance 内核绑三个缓冲:样本 data、质心 c_aMeans、输出 c_aDistance,再把向量维度 m_iVectorSize 作为标量参数压进去。global_work_size 设成 {rows, m_iClusters},意味着每个样本对每一个簇都算一次距离,显存并行度直接拉满。 距离算完必须 c_aDistance.BufferRead() 回读主机侧,否则下一步聚类内核读到的还是旧显存。随后给 kmeans_clustering 内核绑 flags、clusters、distance 三个缓冲,由 GPU 决定每条样本归到最近簇并打标志——这套绑定顺序写错一个索引,聚类结果就会偏到完全不可信。

MQL5 / C++
c_aDistance = new CBufferDouble();
if(CheckPointer(c_aDistance) == POINTER_INVALID)
  class="kw">return class="kw">false;
 }
 c_aDistance.BufferFree();
 if(!c_aDistance.BufferInit(rows * m_iClusters, class="num">0))
  class="kw">return class="kw">false;
 if(CheckPointer(c_aClasters) == POINTER_INVALID)
  {
   c_aClasters = new CBufferDouble();
   if(CheckPointer(c_aClasters) == POINTER_INVALID)
     class="kw">return class="kw">false;
  }
 c_aClasters.BufferFree();
 if(!c_aClasters.BufferInit(rows, class="num">0))
  class="kw">return class="kw">false;
 if(CheckPointer(c_aFlags) == POINTER_INVALID)
  {
   c_aFlags = new CBufferDouble();
   if(CheckPointer(c_aFlags) == POINTER_INVALID)
     class="kw">return class="kw">false;
  }
 c_aFlags.BufferFree();
 if(!c_aFlags.BufferInit(rows, class="num">0))
  class="kw">return class="kw">false;
 if(!data.BufferCreate(c_OpenCL) ||
   !c_aMeans.BufferCreate(c_OpenCL) ||
   !c_aDistance.BufferCreate(c_OpenCL) ||
   !c_aClasters.BufferCreate(c_OpenCL) ||
   !c_aFlags.BufferCreate(c_OpenCL))
   class="kw">return class="kw">false;
 class="type">int count = class="num">0;
 do
   {
    if(!c_OpenCL.SetArgumentBuffer(def_k_kmeans_distance, def_k_kmd_data, data.GetIndex()))
      class="kw">return class="kw">false;
    if(!c_OpenCL.SetArgumentBuffer(def_k_kmeans_distance, def_k_kmd_means, c_aMeans.GetIndex()))
      class="kw">return class="kw">false;
    if(!c_OpenCL.SetArgumentBuffer(def_k_kmeans_distance, def_k_kmd_distance, c_aDistance.GetIndex()))
      class="kw">return class="kw">false;
    if(!c_OpenCL.SetArgument(def_k_kmeans_distance, def_k_kmd_vector_size, m_iVectorSize))
      class="kw">return class="kw">false;
    class="type">uint global_work_offset[class="num">2] = {class="num">0, class="num">0};
    class="type">uint global_work_size[class="num">2];
    global_work_size[class="num">0] = rows;
    global_work_size[class="num">1] = m_iClusters;
    if(!c_OpenCL.Execute(def_k_kmeans_distance, class="num">2, global_work_offset, global_work_size))
      class="kw">return class="kw">false;
    if(!c_aDistance.BufferRead())
      class="kw">return class="kw">false;
    if(!c_OpenCL.SetArgumentBuffer(def_k_kmeans_clustering, def_k_kmc_flags, c_aFlags.GetIndex()))
      class="kw">return class="kw">false;
    if(!c_OpenCL.SetArgumentBuffer(def_k_kmeans_clustering, def_k_kmc_clusters, c_aClasters.GetIndex()))
      class="kw">return class="kw">false;
    if(!c_OpenCL.SetArgumentBuffer(def_k_kmeans_clustering, def_k_kmc_distance, c_aDistance.GetIndex()))
      class="kw">return class="kw">false;

◍ 训练循环与损失值的 OpenCL 落地

KMeans 的训练主循环依赖 OpenCL 内核交替执行聚类与质心更新。先向聚类内核写入簇数参数,再以行数 rows 作为全局工作项规模拉起计算;若标志缓冲读回后最大值仍为 0,说明所有样本已稳定归属,置 m_bTrained 为真并跳出。 未收敛时切换至更新内核:把行情数据、质心、簇标签三个缓冲依次绑定,工作项规模改为 m_iVectorSize(单向量维度),每轮结束用 Comment 打印 Study iterations %d 的迭代计数,方便在 MT5 图表实时盯进度。循环受 IsStopped 与训练成功双重条件约束,避免 EA 卸载时死跑。 损失评估另走 GetLoss:复用 Clustering 拿到标签后,在显存侧算每样本距离并归约。最终 m_dLoss 取所有行损失均值(除以 rows),外汇与贵金属样本的高波动可能让该值在不同品种间差出数量级,仅作模型拟合参考而非方向信号,实盘介入仍属高风险。 下面这段是训练收尾与损失函数的核心片段,注意 c_aFlags.Maximum()==0 是收敛判据,而非固定迭代次数:

MQL5 / C++
if(!c_OpenCL.SetArgument(def_k_kmeans_clustering, def_k_kmc_total_k, m_iClusters))
     class="kw">return class="kw">false;
  class="type">uint global_work_offset1[class="num">1] = {class="num">0};
  class="type">uint global_work_size1[class="num">1];
  global_work_size1[class="num">0] = rows;
  if(!c_OpenCL.Execute(def_k_kmeans_clustering, class="num">1, global_work_offset1, global_work_size1))
     class="kw">return class="kw">false;
  if(!c_aFlags.BufferRead())
     class="kw">return class="kw">false;
  m_bTrained = (c_aFlags.Maximum() == class="num">0);
  if(m_bTrained)
    {
     if(!c_aClasters.BufferRead())
        class="kw">return class="kw">false;
     break;
    }
  if(!c_OpenCL.SetArgumentBuffer(def_k_kmeans_updates, def_k_kmu_data, data.GetIndex()))
     class="kw">return class="kw">false;
  if(!c_OpenCL.SetArgumentBuffer(def_k_kmeans_updates, def_k_kmu_means, c_aMeans.GetIndex()))
     class="kw">return class="kw">false;
  if(!c_OpenCL.SetArgumentBuffer(def_k_kmeans_updates, def_k_kmu_clusters, c_aClasters.GetIndex()))
     class="kw">return class="kw">false;
  if(!c_OpenCL.SetArgument(def_k_kmeans_updates, def_k_kmu_total_m, rows))
     class="kw">return class="kw">false;
  global_work_size[class="num">0] = m_iVectorSize;
  if(!c_OpenCL.Execute(def_k_kmeans_updates, class="num">2, global_work_offset, global_work_size))
     class="kw">return class="kw">false;
  if(!c_aMeans.BufferRead())
     class="kw">return class="kw">false;
  count++;
  Comment(StringFormat("Study iterations %d", count));
   }
 while(!m_bTrained && !IsStopped());
  data.BufferFree();
  c_aDistance.BufferFree();
  c_aFlags.BufferFree();
class=class="str">"cmt">//---
  class="kw">return true;
  }
class="type">class="kw">double CKmeans::GetLoss(CBufferDouble *data)
  {
  if(!Clustering(data))
    class="kw">return -class="num">1;
  class="type">int total = data.Total();
  class="type">int rows = total / m_iVectorSize;
class=class="str">"cmt">//---
  if(CheckPointer(c_aLoss) == POINTER_INVALID)
   {
    c_aLoss = new CBufferDouble();
    if(CheckPointer(c_aLoss) == POINTER_INVALID)
       class="kw">return -class="num">1;
   }
  if(!c_aLoss.BufferInit(rows, class="num">0))
    class="kw">return -class="num">1;
  if(!data.BufferCreate(c_OpenCL) ||
     !c_aLoss.BufferCreate(c_OpenCL))
    class="kw">return -class="num">1;
  m_dLoss = class="num">0;
  for(class="type">int i = class="num">0; i < rows; i++)
    m_dLoss += c_aLoss.At(i);
  m_dLoss /= rows;
  data.BufferFree();
  c_aLoss.BufferFree();
  class="kw">return m_dLoss;
  }

「把 K-means 封装进可序列化的类」

在 MT5 里跑无监督聚类,最省事的做法是把 K-means 整套逻辑包成一个继承自 CObject 的类,这样能直接塞进 CArrayObj 做统一管理,也能复用 MQL5 框架里的虚函数接口。 下面这段代码定义了一个 CKmeans 类,protected 区放了簇数 m_iClusters、向量维度 m_iVectorSize、损失 m_dLoss 和训练标记 m_bTrained,另外挂了 COpenCLMy 指针做 GPU 加速,以及若干个 CBufferDouble 缓冲区存距离、均值、簇标记和损失。 public 区暴露了 Init、Study、Clustering、GetLoss 几个核心方法,其中 Study 的 init_means 参数默认 true,表示首次训练自动初始化质心;Type 函数写死返回 defUnsupervisedKmeans,方便运行时类型区分。 Save 和 Load 都是虚函数,接收文件句柄做模型持久化——这意味着你训好的簇中心能直接落盘,下次 EA 启动不用重算,外汇与贵金属行情高波动下这点能省掉不少重复算力,但请记住模型失效风险始终存在。 开 MT5 新建个类把这段贴进去,把 COpenCLMy 换成你自己的上下文封装,就能在策略里直接调 Clustering 对特征向量做分群。

MQL5 / C++
class CKmeans  : class="kw">public CObject
  {
class="kw">protected:
   class="type">int               m_iClusters;
   class="type">int               m_iVectorSize;
   class="type">class="kw">double            m_dLoss;
   class="type">bool              m_bTrained;
   COpenCLMy        *c_OpenCL;      
   class=class="str">"cmt">//---
   CBufferDouble    *c_aDistance;
   CBufferDouble     *c_aMeans;
CBufferDouble    *c_aClasters;
   CBufferDouble    *c_aFlags;
   CBufferDouble    *c_aLoss;
class="kw">public:
                    CKmeans(class="type">void);
                   ~CKmeans(class="type">void);
   class=class="str">"cmt">//---
   class="type">bool             SetOpenCL(COpenCLMy *context);
   class="type">bool             Init(COpenCLMy *context, class="type">int clusters, class="type">int vector_size);
   class="type">bool             Study(CBufferDouble *data, class="type">bool init_means = true);
   class="type">bool             Clustering(CBufferDouble *data);
   class="type">class="kw">double           GetLoss(CBufferDouble *data);
   class=class="str">"cmt">//---
   class="kw">virtual class="type">bool     Save(class="kw">const class="type">int file_handle);
   class="kw">virtual class="type">bool     Load(class="kw">const class="type">int file_handle);
   class=class="str">"cmt">//---
   class="kw">virtual class="type">int      Type(class="type">void)   { class="kw">return defUnsupervisedKmeans; }
   };

常见问题

先确认设备支持与上下文创建成功,再把输入输出缓冲区按样本维度绑定好,避免空指针导致内核无法启动。
原文给出硬约束:样本量必须不小于聚类数乘特征维数,否则矩阵无法构成,建议先在小数据集上跑通再放大。
可以,小布能读取你的训练日志并标注损失突变或内核超时,把重复监控交给它,你只管调参决策。
确保 centroid 与分配标签随类一起写入文件,且读取端 OpenCL 设备名做兼容兜底,防止上下文错配。
多数情况是无报错但结果乱跳,可用小样本打印缓冲区头尾数值核对绑定,再上全量数据。