神经网络变得轻松(第十六部分):聚类运用实践(基础篇)
◍ 把聚类结果直接当成交易信号
在 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 上循环搬概率即可。外汇与贵金属市场杠杆高、滑点无常,此类统计概率仅作仓位参考,不构成方向保证。
__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*簇数 填满。
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 那一行是不是被别的改动误伤了。外汇与贵金属波动剧烈,这类内存预留只是工程稳妥动作,不预示任何方向概率优势。
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; }