神经网络变得轻松(第十五部分):利用 MQL5 进行数据聚类·进阶篇
把 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 程序时两边必须对齐。
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 由外部注入。
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 必然失败。 外汇与贵金属杠杆高、滑点无常,这类聚类特征用于辅助判断形态分布,不代表信号必然有效,实盘前请在策略测试器用历史数据验证样本量阈值。
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 决定每条样本归到最近簇并打标志——这套绑定顺序写错一个索引,聚类结果就会偏到完全不可信。
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 是收敛判据,而非固定迭代次数:
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 对特征向量做分群。
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; } };