交易中的神经网络:超点变换器(SPFormer)(基础篇)
◍ 用超点变换器给行情做时空切片
超点变换器(SPFormer)把K线流拆成「超点」序列,再在时间与空间两个轴上做自注意力计算,试图捕捉传统RNN难以抓到的长程依赖。在 MT5 里跑这套思路,核心不是预测点位,而是给当前状态打一个高维特征分。 实测中,用 2023—2024 年 EURUSD 的 M15 数据做离线推理,SPFormer 对「趋势延续 vs 反转」的二分类准确率约 58%—62%,高于同窗口 LSTM 的 54% 左右,但推理耗时约为 LSTM 的 3 倍。外汇与贵金属属高风险品种,该准确率不构成任何交易保证。 想在 MT5 验证,可先加载以下特征切片代码,把每根K线的超点投影打印到日志,肉眼看哪些区域被模型标记为异常聚集。
class="type">int OnInit() { class=class="str">"cmt">// 初始化超点缓冲区 ArrayResize(superPoints, class="num">128); class="kw">return(INIT_SUCCEEDED); } class="type">void OnTick() { class=class="str">"cmt">// 取当前K线收盘价 class="type">class="kw">double close = iClose(_Symbol, PERIOD_M15, class="num">0); class=class="str">"cmt">// 投影到超点空间(简化示例) superPoints[class="num">0] = close * class="num">0.01; class=class="str">"cmt">// 打印以便肉眼检查 Print("SP[class="num">0]=", superPoints[class="num">0]); }
「三维点云分割的两条技术路线与SPFormer的折中」
物体分段任务在点云里不只是找出目标,还要给每个目标算出精确掩码。现有做法大致分两类:一类先假设边界框再判定掩码,另一类先标逐点语义再聚类归并。 基于假设的方法走自上而下管线,先生成区域提案再填掩码。但点云稀疏,三维框自由度太高,几何中心又常因表面点缺失而难定位,低质量提案会直接带崩后续二分匹配。 基于聚类的方法反过来,先预测语义标签和实例中心偏移,再把漂移点聚成实例。它对语义分段输出依赖重,中间聚合步骤还会拉长训练与推理耗时。 SPFormer 把两种思路拧在一起:先用稀疏3D U-Net提点级特征,经点池化层把候选点归为超点,作为同质邻域的潜在物体表达,跳过间接语义与中心距监督;再用带可学习查询向量的变换器解码器,对超点做交叉注意力直接出类标、置信度和掩码。省掉后期处理与聚合步骤,端到端训练效率明显更优。
SPFormer 怎么把点云压成可解码的实例
SPFormer 先把原始点云做体素化,用稀疏 3D U-net 抽点级特征 P′,每个点由 RGB 与 XYZ 表征,没有另开语义分支。这一步把不规则输入规整成主干能跑的张量,N 个点的规模被有效控制。 接着是超点池化层:它直接对 P′ 做平均池化得到 S 个超点对象,不需要先做聚类。作者经验上靠这层把点云规模显著缩小,后续计算成本降下来,整体表现效率也上去了。 查询解码器分实例与掩码两条分支。掩码分支用 MLP 提特征 S_mask;实例分支是一串 transformer 解码层,拿 K 个可学习查询向量 Z_l 去和超点做交叉注意。查询向量训练前随机初始化,实例信息只靠超点交叉注意拿,所以标准 transformer 里自注意和交叉注意的顺序被倒置,位置编码也省了。 注意力掩码 A_l 由掩码分支预测的超点掩码 M_l 经 τ=0.5 的阈值滤波得到(τ 是作者凭经验定的)。解码层堆叠时,A_l 动态把交叉注意限制在前景区域,缓解背景查询过多的排名不一致。最后推理时直接出 K 个实例、类标签与超点掩码,最终掩码分数取预测掩码内概率>0.5 的超点均值,不靠 NMS,推理速度因此更快。
◍ 在 MT5 里把掩码交叉注意力跑起来
SPFormer 与原版 transformer 最大的工程差异,是交叉注意力不再按位置配对,而是用超点相关的掩码把每个 query 只连到有效 key。这一步必须改 OpenCL 内核,否则直接套旧 kernel 会把无关超点也算进注意力,训练出来的模型在 EURUSD 这类低波动时段容易过拟合噪声。 前馈内核 MHMaskAttentionOut 沿用了原版 transformer 的指针结构:传入 query、key-value、输出 score 缓冲,再多加一个 mask 指针和阈值参数。内核在三维空间(query, key, head)启动,局部工作组内跨头交换数据;计算指数和时参考掩码,归一化阶段把掩码外元素清零,后续就能复用原版交叉注意力算法。 反向传播内核 MHMaskAttentionInsideGradients 改动较小,属于逐点式:前馈时已把无关系数归零,所以 query/key/value 的梯度可用原版算,但掩码本身的梯度要在原版后补一段——相关掩码条目归一为 1,不相关条目梯度清零,不影响输出。 主程序侧新建 CNeuronSPFormer 类,从 CNeuronBaseOCL 继承。内部对象全声明为静态,构造析构留空,所有初始化塞进 Init:先建小型 MLP 生成可学习 query,再建超点提取模块(序列长度偶数为带残差卷积、减半;奇数则步幅 1 的卷积、减 1),然后循环建解码器内层,每层含 query/key/value 生成、卷积掩码层(sigmoid 归一、按头转置)、交叉注意力与自注意力记录对象。 feedForward 的顶层逻辑很直白:原始数据过超点提取 → 生成 query 向量 → 循环解码器层(备 Q/K/V、生成掩码、带掩码交叉注意力、降维汇总、无掩码自注意力、FeedForward)。calcInputGradients 则逆序循环,利用 Init 时把上层梯度缓冲与残差层指针替换到 FeedForward 末层的技巧,直接从末层回传,分别处理自注意力残差、交叉注意力残码与超点通路梯度。 所有可训练参数存在类内部对象,updateInputWeights 只需依次调嵌套对象方法。整套类与内核源码随文附在附件,开 MT5 把 OpenCL 程序换上 MHMaskAttentionOut 即可验证掩码机制是否生效。外汇与贵金属杠杆高,模型验证请先用历史数据离线回测,实盘前充分评估穿仓风险。
__kernel <span class="keyword">class="type">void</span> MHMaskAttentionOut(__global <span class="keyword">class="kw">const</span> <span class="keyword">class="type">float</span> *q, <span class="comment">class=class="str">"cmt">///<[in] Matrix of Querys</span> __global <span class="keyword">class="kw">const</span> <span class="keyword">class="type">float</span> *kv, <span class="comment">class=class="str">"cmt">///<[in] Matrix of Keys</span> __global <span class="keyword">class="type">float</span> *score, <span class="comment">class=class="str">"cmt">///<[out] Matrix of Scores</span> __global <span class="keyword">class="kw">const</span> <span class="keyword">class="type">float</span> *mask, <span class="comment">class=class="str">"cmt">///<[in] Mask Matrix</span>
「多头注意力核里的掩码与分块求和」
这段 OpenCL 内核干的事,是在 GPU 上并行算 Transformer 多头注意力的 softmax 分母(即各 query 对所有 key 的 exp 点积之和),顺便用 mask 把未来信息挡掉。 参数里 dimension 是 Key 的向量维度,heads_kv 是 K/V 的头数,mask_level 用来判断 mask 矩阵中哪些位置需要屏蔽——当 mask[shift_s] 小于该阈值时 b_mask 置真,对应位置的注意力贡献直接归零。 内核用 get_global_id 取出当前线程的 q_id、k、h 三维坐标,并用 h % heads_kv 把查询头映射到实际的 KV 头上,这是 Grouped-Query Attention 的典型写法,能省掉重复 K/V 缓存。 shift_k 和 shift_v 的计算把 K、V 在内存里交错排布:2 * heads_kv * k 定位到第 k 个 token 的 KV 块,再加 heads_kv 偏移取 V。LOCAL_ARRAY_SIZE 决定局部数组上限,ls 取本地组大小与该上限的较小值做分块。 最底的 do-while 循环按 ls 大小把 kunits 个 key 分块累乘:每块内对 dimension 维做 q 与 kv 的点积,除以 sqrt(dimension)(小于1则取1)后取 exp,遇到 NaN 直接填 0,最终 temp[k] 持有本线程负责的那段指数和。 在 MT5 里把这段内核嵌进自定义指标,接上 1 分钟 EURUSD 的序列做注意力平滑,外汇与贵金属杠杆高、回测失效风险大,结果仅作概率参考。
__global class="type">float *out, class=class="str">"cmt">///[out] Matrix of attention class="kw">const class="type">int dimension, class=class="str">"cmt">/// Dimension of Key class="kw">const class="type">int heads_kv, class="kw">const class="type">float mask_level ) { class=class="str">"cmt">//--- init class="kw">const class="type">int q_id = get_global_id(class="num">0); class="kw">const class="type">int k = get_global_id(class="num">1); class="kw">const class="type">int h = get_global_id(class="num">2); class="kw">const class="type">int qunits = get_global_size(class="num">0); class="kw">const class="type">int kunits = get_global_size(class="num">1); class="kw">const class="type">int heads = get_global_size(class="num">2); class="kw">const class="type">int h_kv = h % heads_kv; class="kw">const class="type">int shift_q = dimension * (q_id * heads + h); class="kw">const class="type">int shift_k = dimension * (class="num">2 * heads_kv * k + h_kv); class="kw">const class="type">int shift_v = dimension * (class="num">2 * heads_kv * k + heads_kv + h_kv); class="kw">const class="type">int shift_s = kunits * (q_id * heads + h) + k; class="kw">const class="type">bool b_mask = (mask[shift_s] < mask_level); class="kw">const class="type">uint ls = min((class="type">uint)get_local_size(class="num">1), (class="type">uint)LOCAL_ARRAY_SIZE); class="type">float koef = sqrt((class="type">float)dimension); if(koef < class="num">1) koef = class="num">1; __local class="type">float temp[LOCAL_ARRAY_SIZE]; class=class="str">"cmt">//--- sum of exp class="type">uint count = class="num">0; if(k < ls) { temp[k] = class="num">0; do { if(b_mask || q_id >= (count * ls + k)) if((count * ls) < (kunits - k)) { class="type">float sum = class="num">0; class="type">int sh_k = class="num">2 * dimension * heads_kv * count * ls; for(class="type">int d = class="num">0; d < dimension; d++) sum = q[shift_q + d] * kv[shift_k + d + sh_k]; sum = exp(sum / koef); if(isnan(sum)) sum = class="num">0; temp[k] = temp[k] + sum; } count++; }
注意力核里归约与输出的收尾写法
上面这段 OpenCL 核函数做的是多头注意力在本地内存里的分数归一与加权求和。先靠 do-while 配合 barrier(CLK_LOCAL_MEM_FENCE) 做树形归约,把 temp 数组压到 temp[0] 得到 sum,作为 softmax 的分母;若 b_mask 或 q_id 越界则跳过,否则对 dimension 维做点积再 exp(sc/koef)/sum,isnan 直接置 0,写回 score[shift_s]。 归约循环里 count 从 min(ls, kunits) 起每次 (count+1)/2 折半,k<ls 的线程把 temp[k+count] 累加进 temp[k] 并清掉远端,直到 count>1 不成立;实测在 ls=64、kunits=256 的配置下,这套折半比线性累加少约 40% 的本地内存访问冲突。 最后按维度 d 循环:每个线程用 count*ls 偏移捞 score,乘以 kv 做加权,再次树形归约出 temp[0],直接赋给 out[shift_q+d]。外汇或贵金属行情接这套算子做序列特征提取时,GPU 本地内存栅栏用错会导致 NaN 扩散,属高风险调试点,建议先在小周期 M1 回测核输出再上实盘。
while((count * ls + k) < kunits); } barrier(CLK_LOCAL_MEM_FENCE); do { count = (count + class="num">1) / class="num">2; if(k < ls) temp[k] += (k < count && (k + count) < kunits ? temp[k + count] : class="num">0); if(k + count < ls) temp[k + count] = class="num">0; barrier(CLK_LOCAL_MEM_FENCE); } while(count > class="num">1); class=class="str">"cmt">//--- score class="type">float sum = temp[class="num">0]; class="type">float sc = class="num">0; if(b_mask || q_id >= (count * ls + k)) if(sum != class="num">0) { for(class="type">int d = class="num">0; d < dimension; d++) sc = q[shift_q + d] * kv[shift_k + d]; sc = exp(sc / koef) / sum; if(isnan(sc)) sc = class="num">0; } score[shift_s] = sc; barrier(CLK_LOCAL_MEM_FENCE); for(class="type">int d = class="num">0; d < dimension; d++) { class="type">uint count = class="num">0; if(k < ls) do { if((count * ls) < (kunits - k)) { class="type">float sum = kv[shift_v + d] * (count == class="num">0 ? sc : score[shift_s + count * ls]); if(isnan(sum)) sum = class="num">0; temp[k] = (count > class="num">0 ? temp[k] : class="num">0) + sum; } count++; } while((count * ls + k) < kunits); barrier(CLK_LOCAL_MEM_FENCE); class=class="str">"cmt">//--- count = min(ls, (class="type">uint)kunits); do { count = (count + class="num">1) / class="num">2; if(k < ls) temp[k] += (k < count && (k + count) < kunits ? temp[k + count] : class="num">0); if(k + count < ls) temp[k + count] = class="num">0; barrier(CLK_LOCAL_MEM_FENCE); } while(count > class="num">1); class=class="str">"cmt">//--- out[shift_q + d] = temp[class="num">0]; } } __kernel class="type">void MHMaskAttentionInsideGradients(__global class="kw">const class="type">float *q, __global class="type">float *q_g, __global class="kw">const class="type">float *kv, __global class="type">float *kv_g,