神经网络变得轻松(第十六部分):聚类运用实践·进阶篇
(2/3)·聚类不是终点,如何独立决策或喂给下一层模型,这篇给你可跑的代码路径
「把聚类距离喂给二级模型」
k-means 跑完之后,第二种用法是把聚类结果直接当成另一个模型的输入。这个距离向量不是原始坐标,而是到各个聚类中心距离的归一化表达,下游可以接任意监督模型,比如三层全连接感知器。 在 OpenCL 程序里我们新增内核 KmeansSoftMax,不重算距离(那活儿 KmeanscucDistance 已经干了),只做归一化。它接收 distance 源缓冲区和 softmax 结果缓冲区,两个都是「样本数 × 聚类数」的矩阵布局,按一维任务空间每线程处理一个样本。 归一化逻辑很直白:先在线程内找本行最大距离 m(距离都是平方偏差,必为正),再算每个元素的 exp(1 - d/m) 并累加得 sum,最后整行除以 sum。用私密变量暂存能少碰全局内存。下面这段就是内核本体。 [CODE] __kernel void KmeansSoftMax(__global double *distance, __global double *softmax, int total_k) { int i = get_global_id(0); int shift = i * total_k; double m=distance[shift]; for(int k = 1; k < total_k; k++) m = max(distance[shift + k],m); double sum = 0; for(int k = 0; k < total_k; k++) { double value = exp(1-distance[shift + k]/m); sum += value; softmax[shift + k] = value; } for(int k = 0; k < total_k; k++) softmax[shift + k] /= sum; } #define def_k_kmeans_softmax 5 #define def_k_kmsm_distance 0 [/CODE] 逐行拆解:第 1–4 行是内核签名,distance 进、softmax 出、total_k 是聚类总数;第 6 行取线程 ID 当作样本序号;第 7 行算该行在缓冲区的起始偏移;第 8–10 行用首元素初始化最大值 m 再扫一遍找最大距离;第 12–18 行边算指数边累加 sum 并写回 softmax;第 20–21 行整行除以 sum 完成归一化。 CKmeans 类里加 SoftMax 方法:先查模型训过没,没训过就限制样本规模、训完解限;指针和缓冲区大小都要复核,避免拿无效指针往下跑崩。调用顺序就是先 KmeanscultDistance 算距、再 KmeansSoftMax 归一。 实测智能系统 kmeans_net.mq5 把 500 个预训练聚类模型接进三隐藏层感知器,历史数据转成时间序列再送进去。训练图显示前 20 世代损失平缓下降(Adam 优化、动量累积),之后陡降到极小值;比起直接监督的注意力模型转折更利落,说明先聚类再监督对简单模型效率提升明显。外汇与贵金属行情高风险,这套管线只解决特征表达,不承诺任何方向概率优势,请自行在 MT5 用附件代码回测验证。
__kernel <span class="keyword">class="type">void</span> KmeansSoftMax(__global <span class="keyword">class="type">class="kw">double</span> *distance, __global <span class="keyword">class="type">class="kw">double</span> *softmax, <span class="keyword">inсt</span> total_k ) { <span class="keyword">class="type">int</span> i = get_global_id(<span class="number">class="num">0</span>); <span class="keyword">class="type">int</span> shift = i * total_k; <span class="keyword">class="type">class="kw">double</span> m=distance[shift]; <span class="keyword">for</span>(<span class="keyword">class="type">int</span> k = <span class="number">class="num">1</span>; k < total_k; k++) m = max(distance[shift + k],m); <span class="keyword">class="type">class="kw">double</span> sum = <span class="number">class="num">0</span>; <span class="keyword">for</span>(<span class="keyword">class="type">int</span> k = <span class="number">class="num">0</span>; k < total_k; k++) { <span class="keyword">class="type">class="kw">double</span> value = exp(<span class="number">class="num">1</span>-distance[shift + k]/m); sum += value; softmax[shift + k] = value; } <span class="keyword">for</span>(<span class="keyword">class="type">int</span> k = <span class="number">class="num">0</span>; k < total_k; k++) softmax[shift + k] /= sum; } <span class="preprocessor">class="macro">#define </span>def_k_kmeans_softmax <span class="number">class="num">5</span> <span class="preprocessor">class="macro">#define </span>def_k_kmsm_distance <span class="number">class="num">0</span>
在 OpenCL 里跑 Kmeans 距离与 SoftMax 分配
把 K 均值聚类搬到 GPU 上算,核心是先建好 OpenCL 上下文并注册 6 个 kernel,其中第 2 类 kernel 专门负责 SoftMax 概率化。下面这段工厂函数里,SetKernelsCount(6) 失败就直接 delete 并返回 NULL,KernelCreate 绑定名为 "KmeansSoftMax" 的入口,少一个都进不了后续计算。 SoftMax 方法本身先做防御:未训练且 Study 失败、指针无效、数据条数不能被向量维数整除,全部直接返 NULL。它按 total / m_iVectorSize 算出样本行数 rows,给距离矩阵和 SoftMax 矩阵各开 rows * m_iClusters 的双精度缓冲并清零。 真正下发任务前,要把数据缓冲、均值缓冲、距离缓冲、SoftMax 缓冲全部 BufferCreate 到 OpenCL 设备,再用 SetArgumentBuffer 把索引绑到对应 kernel 参数槽。最后设 global_work_offset 为 {0,0},global_work_size[0] 取 rows——意味着每个样本行派一个 work item 并行算距离。外汇与贵金属行情高频,这种并行在 30 分钟以上周期做状态聚类可能降低 CPU 占用,但模型误分概率仍受训练样本偏差影响,属高风险用法。 别把缓冲创建当免检环节 哪怕 CPU 端 new 成功,BufferCreate 到 OpenCL 失败也会静默返 NULL;实盘前应在 MT5 策略测试器里故意传空指针跑一遍,确认所有早退分支都按预期触发而不是崩终端。
class="macro">#define def_k_kmsm_softmax class="num">1 class="macro">#define def_k_kmsm_total_k class="num">2 COpenCLMy *OpenCLCreate(class="type">class="kw">string programm) { ............... class=class="str">"cmt">//--- if(!result.SetKernelsCount(class="num">6)) { class="kw">delete result; .class="kw">return NULL; } class=class="str">"cmt">//--- ............... class=class="str">"cmt">//--- if(!result.KernelCreate(def_k_kmeans_softmax, "KmeansSoftMax")) { class="kw">delete result; .class="kw">return NULL; } class=class="str">"cmt">//--- class="kw">return result; } CBufferDouble *CKmeans::SoftMax(CBufferDouble *data) { if(!m_bTrained && !Study(data, (c_aMeans.Maximum() == class="num">0))) .class="kw">return NULL; if(CheckPointer(data) == POINTER_INVALID || CheckPointer(c_OpenCL) == POINTER_INVALID) .class="kw">return NULL; 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 NULL; class="type">int rows = total / m_iVectorSize; if(rows < class="num">1) .class="kw">return NULL; if(CheckPointer(c_aDistance) == POINTER_INVALID) { c_aDistance = new CBufferDouble(); if(CheckPointer(c_aDistance) == POINTER_INVALID) .class="kw">return NULL; } c_aDistance.BufferFree(); if(!c_aDistance.BufferInit(rows * m_iClusters, class="num">0)) .class="kw">return NULL; if(CheckPointer(c_aSoftMax) == POINTER_INVALID) { c_aSoftMax = new CBufferDouble(); if(CheckPointer(c_aSoftMax) == POINTER_INVALID) .class="kw">return NULL; } c_aSoftMax.BufferFree(); if(!c_aSoftMax.BufferInit(rows * m_iClusters, class="num">0)) .class="kw">return NULL; if(!data.BufferCreate(c_OpenCL) || !c_aMeans.BufferCreate(c_OpenCL) || !c_aDistance.BufferCreate(c_OpenCL) || !c_aSoftMax.BufferCreate(c_OpenCL)) .class="kw">return NULL; if(!c_OpenCL.SetArgumentBuffer(def_k_kmeans_distance, def_k_kmd_data, data.GetIndex())) .class="kw">return NULL; if(!c_OpenCL.SetArgumentBuffer(def_k_kmeans_distance, def_k_kmd_means, c_aMeans.GetIndex())) .class="kw">return NULL; if(!c_OpenCL.SetArgumentBuffer(def_k_kmeans_distance, def_k_kmd_distance, c_aDistance.GetIndex())) .class="kw">return NULL; if(!c_OpenCL.SetArgument(def_k_kmeans_distance, def_k_kmd_vector_size, m_iVectorSize)) .class="kw">return NULL; 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;
◍ GPU 上跑完距离与软最大化再载回权重
这段逻辑先在 OpenCL 设备里算完样本到各聚类中心的距离,再调 softmax 核把距离转成概率分布,最后把结果读回主机内存。global_work_size[1] 被设成聚类数 m_iClusters,二维调度里第二维正好覆盖全部簇,避免 CPU 端逐簇循环。 执行 def_k_kmeans_distance 后若 BufferRead 失败直接返 NULL,说明显存到内存的回读断了,MT5 终端里会看到指标算不出值。softmax 核只用一维、global_work_size1[0]=rows,即按样本行并行,和前面距离核的二维结构不一样,写核函数时维度别抄错。 Train 函数里先按 StudyPeriod 年回拨起点,比如 StudyPeriod=10 且当前 2024 年,起点年份会被推到 2014;CopyRates 拉到的 bars 数量直接决定 RSI/CCI/ATR/MACD 四个缓冲的 resize 大小,少一个 resize 成功就 ExpertRemove 自毙。 模型文件用 kmeans_%d.net 的二进制名,Clusters 不同文件名就不同;FileReadInteger 读出的类型码和 Kmeans.Type() 对不上也会直接停 EA。total = bars - HistoryBars - 1 这一行给后续训练游标留了边界,HistoryBars 设太大可能让 total 变负,回测时容易无声崩掉。
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 NULL; if(!c_aDistance.BufferRead()) class="kw">return NULL; if(!c_OpenCL.SetArgumentBuffer(def_k_kmeans_softmax, def_k_kmsm_distance, c_aDistance.GetIndex())) class="kw">return NULL; if(!c_OpenCL.SetArgumentBuffer(def_k_kmeans_softmax, def_k_kmsm_softmax, c_aSoftMax.GetIndex())) class="kw">return NULL; if(!c_OpenCL.SetArgument(def_k_kmeans_softmax, def_k_kmsm_total_k, m_iClusters)) class="kw">return NULL; 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_softmax, class="num">1, global_work_offset1, global_work_size1)) class="kw">return NULL; if(!c_aSoftMax.BufferRead()) class="kw">return NULL; data.BufferFree(); c_aDistance.BufferFree(); class=class="str">"cmt">//--- class="kw">return c_aSoftMax; } class="type">void Train(class="type">class="kw">datetime StartTrainBar = class="num">0) { COpenCLMy *opencl = OpenCLCreate(cl_unsupervised); if(CheckPointer(opencl) == POINTER_INVALID) { ExpertRemove(); class="kw">return; } if(!Kmeans.SetOpenCL(opencl)) { class="kw">delete opencl; ExpertRemove(); class="kw">return; } class="type">MqlDateTime start_time; TimeCurrent(start_time); start_time.year -= StudyPeriod; if(start_time.year <= class="num">0) start_time.year = class="num">1900; class="type">class="kw">datetime st_time = StructToTime(start_time); class="type">int bars = CopyRates(Symb.Name(), TimeFrame, st_time, TimeCurrent(), Rates); if(!RSI.BufferResize(bars) || !CCI.BufferResize(bars) || !ATR.BufferResize(bars) || !MACD.BufferResize(bars)) { ExpertRemove(); class="kw">return; } if(!ArraySetAsSeries(Rates, true)) { ExpertRemove(); class="kw">return; } RSI.Refresh(); CCI.Refresh(); ATR.Refresh(); MACD.Refresh(); class="type">int handl = FileOpen(StringFormat("kmeans_%d.net", Clusters), FILE_READ | FILE_BIN); if(handl == INVALID_HANDLE) { ExpertRemove(); class="kw">return; } if(FileReadInteger(handl) != Kmeans.Type()) { ExpertRemove(); class="kw">return; } class="type">bool result = Kmeans.Load(handl); FileClose(handl); if(!result) { ExpertRemove(); class="kw">return; } class="type">int total = bars - (class="type">int)HistoryBars - class="num">1;
「把K线特征塞进训练缓冲区的写法」
做行情模式识别时,第一步是把每根历史bar的几何与指标特征铺平成定长向量。下面这段逻辑给 data 数组按 total*8*HistoryBars 扩容,给 fractals 按 total*3 扩容;只要任一 ArrayResize 返回 ≤0 就直接 ExpertRemove 退出,避免后面越界写崩 EA。 外层循环 i 跑 total 个样本,内层 b 跑 HistoryBars 根 bar,每个样本在 data 里占 8 个 double 槽位:开盘到最低/最高/收盘的偏移各 1 个,再加 RSI、CCI、ATR、MACD主线、MACD信号线共 5 个指标值。shift 计算成 (i*HistoryBars+b)*8,读者开 MT5 把 HistoryBars 设成 20、total 设成 500,就能直观看到缓冲区被撑到 80000 个 double。 fractals 每样本占 3 位:上分形、下分形用相邻三根 bar 的高低比较得出,第三位写的是「上下分形都不成立」的标记(注意原文 fractals[shift]+fractals[shift] 是笔误,本意应是上+下分形之和为0)。循环里用 Comment 打印进度,并用 IsStopped 做中断兜底,手动停止测试时不会卡死在万级循环里。 最后把两个裸数组 AssignArray 给 CBufferDouble,再调 Kmeans.SoftMax 拿到概率分布;任一步指针无效就 printf 错误码并移除 EA。外汇与贵金属杠杆高,这类离线特征构建只在历史数据上跑,实盘前务必用小资金验证聚类稳定性。
class="type">class="kw">double data[], fractals[]; if(ArrayResize(data, total * class="num">8 * HistoryBars) <= class="num">0 || ArrayResize(fractals, total * class="num">3) <= class="num">0) { ExpertRemove(); class="kw">return; } class=class="str">"cmt">//--- for(class="type">int i = class="num">0; (i < total && !IsStopped()); i++) { Comment(StringFormat("Create data: %d of %d", i, total)); for(class="type">int b = class="num">0; b < (class="type">int)HistoryBars; b++) { class="type">int bar = i + b; class="type">int shift = (i * (class="type">int)HistoryBars + b) * class="num">8; class="type">class="kw">double open = Rates[bar].open; data[shift] = open - Rates[bar].low; data[shift + class="num">1] = Rates[bar].high - open; data[shift + class="num">2] = Rates[bar].close - open; data[shift + class="num">3] = RSI.GetData(MAIN_LINE, bar); data[shift + class="num">4] = CCI.GetData(MAIN_LINE, bar); data[shift + class="num">5] = ATR.GetData(MAIN_LINE, bar); data[shift + class="num">6] = MACD.GetData(MAIN_LINE, bar); data[shift + class="num">7] = MACD.GetData(SIGNAL_LINE, bar); } class="type">int shift = i * class="num">3; class="type">int bar = i + class="num">1; fractals[shift] = (class="type">int)(Rates[bar - class="num">1].high <= Rates[bar].high && Rates[bar + class="num">1].high < Rates[bar].high); fractals[shift + class="num">1] = (class="type">int)(Rates[bar - class="num">1].low >= Rates[bar].low && Rates[bar + class="num">1].low > Rates[bar].low); fractals[shift + class="num">2] = (class="type">int)((fractals[shift] + fractals[shift]) == class="num">0); } if(IsStopped()) { ExpertRemove(); class="kw">return; } CBufferDouble *Data = new CBufferDouble(); if(CheckPointer(Data) == POINTER_INVALID || !Data.AssignArray(data)) class="kw">return; CBufferDouble *Fractals = new CBufferDouble(); if(CheckPointer(Fractals) == POINTER_INVALID || !Fractals.AssignArray(fractals)) class="kw">return; ResetLastError(); CBufferDouble *softmax = Kmeans.SoftMax(Data); if(CheckPointer(softmax) == POINTER_INVALID) { printf("Runtime error %d", GetLastError()); ExpertRemove(); class="kw">return; } if(CheckPointer(TempData) == POINTER_INVALID) { TempData = new CArrayDouble(); if(CheckPointer(TempData) == POINTER_INVALID) { ExpertRemove();
回测循环里的内存自清与随机抽桩
这段逻辑跑在 EA 的回测主循环里,先 delete opencl 释放上一次的开收盘对象,再把 dUndefine、dForecast、dError、dPrevSignal 全部归零,count 从 0 起步,准备进入 do-while 的迭代。 循环体先用 prev_un / prev_for / prev_er 暂存上轮信号数值,随后用 IsStopped() 探一次中止标志;只要终端点了停止,后面的双重 for 直接不进。 内层 for 的遍历上限是 total - 300,意味着回测刻意留了最后 300 根 K 线不碰——这是为了避免样本尾部过拟合,实操里你可以把 300 改成 500 看曲线是否更稳。 抽桩位置 i 不是顺序走的,而是用 MathRand() 的平方除以 32767 的平方再做缩放,等于在 [300, total) 区间做非线性随机采样;这种加权随机更偏向中前段数据。 TempData.Reserve(Clusters) 一旦失败,代码会连环 delete Data / Fractals / softmax / opencl 四个指针对象,清 Comment 后调 ExpertRemove() 自卸 EA——说明内存预约失败在这里被视作致命错误,不是打个日志就跳过。
class="kw">return; } } } class="kw">delete opencl; class="type">class="kw">double prev_un, prev_for, prev_er; dUndefine = class="num">0; dForecast = class="num">0; dError = -class="num">1; dPrevSignal = class="num">0; class="type">bool stop = class="kw">false; class="type">int count = class="num">0; do { prev_un = dUndefine; prev_for = dForecast; prev_er = dError; ENUM_SIGNAL bar = Undefine; class=class="str">"cmt">//--- stop = IsStopped(); for(class="type">int it = class="num">0; (it < total - class="num">300 && !IsStopped()); it++) { class="type">int i = (class="type">int)((MathRand() * MathRand() / MathPow(class="num">32767, class="num">2)) * (total - class="num">300)) + class="num">300; TempData.Clear(); class="type">int shift = i * Clusters; if(!TempData.Reserve(Clusters)) { if(CheckPointer(Data) == POINTER_DYNAMIC) class="kw">delete Data; if(CheckPointer(Fractals) == POINTER_DYNAMIC) class="kw">delete Fractals; if(CheckPointer(softmax) == POINTER_DYNAMIC) class="kw">delete softmax; if(CheckPointer(opencl) == POINTER_DYNAMIC) class="kw">delete opencl; Comment(""); class=class="str">"cmt">//--- ExpertRemove(); class="kw">return; } for(class="type">int c = class="num">0; c < Clusters; c++) if(!TempData.Add(softmax.At(shift + c))) { if(CheckPointer(Data) == POINTER_DYNAMIC) class="kw">delete Data; if(CheckPointer(Fractals) == POINTER_DYNAMIC) class="kw">delete Fractals; if(CheckPointer(softmax) == POINTER_DYNAMIC) class="kw">delete softmax; if(CheckPointer(opencl) == POINTER_DYNAMIC) class="kw">delete opencl;
◍ 神经网络推理失败时的资源回收
当 Net.feedForward(TempData) 返回 false,说明前向传播没跑通,EA 必须立刻清场退出,否则悬空指针会拖垮后续 tick。这里用 CheckPointer 逐个确认动态对象再 delete,是 MT5 里防内存泄漏的标准动作。
清理顺序覆盖了 Data、Fractals、softmax、opencl 四个动态指针,确认类型等于 POINTER_DYNAMIC 才释放,避免对栈对象误删。清完调 Comment("") 抹掉图表上的旧文字,再 ExpertRemove() 自卸载并 return。
正常分支里,前向通过后先用 getResults 取原始输出,随后手动做 softmax:对 3 个节点取 exp 累加得 sum,再逐项除以 sum,得到的概率和恒为 1。若 sum 为 0 则跳过归一化,防止除零崩 EA。
TempData.Maximum(0,3) 挑出最大概率下标,case 1 视为偏多信号、case 2 偏空、其余归零,信号值直接取对应节点概率。外汇与贵金属波动剧烈,这类模型输出只代表概率倾向,实盘仍需严格风控。
Comment(""); class=class="str">"cmt">//--- ExpertRemove(); class="kw">return; } if(!Net.feedForward(TempData)) { if(CheckPointer(Data) == POINTER_DYNAMIC) class="kw">delete Data; if(CheckPointer(Fractals) == POINTER_DYNAMIC) class="kw">delete Fractals; if(CheckPointer(softmax) == POINTER_DYNAMIC) class="kw">delete softmax; if(CheckPointer(opencl) == POINTER_DYNAMIC) class="kw">delete opencl; Comment(""); class=class="str">"cmt">//--- ExpertRemove(); class="kw">return; } Net.getResults(TempData); class="type">class="kw">double sum = class="num">0; for(class="type">int res = class="num">0; res < class="num">3; res++) { class="type">class="kw">double temp = exp(TempData.At(res)); sum += temp; TempData.Update(res, temp); } for(class="type">int res = class="num">0; (res < class="num">3 && sum > class="num">0); res++) TempData.Update(res, TempData.At(res) / sum); class="kw">switch(TempData.Maximum(class="num">0, class="num">3)) { case class="num">1: dPrevSignal = (TempData[class="num">1] != TempData[class="num">2] ? TempData[class="num">1] : class="num">0); class="kw">break; case class="num">2: dPrevSignal = -TempData[class="num">2]; class="kw">break; class="kw">default: dPrevSignal = class="num">0; class="kw">break; } class="type">class="kw">string s = StringFormat("Study -> Era %d -> %.2f -> Undefine %.2f%% foracast %.2f%%\n %d of %d -> %.2f%%