
交易中的神经网络:超点变换器(SPFormer)
概述
物体分段是一项复杂的场景理解任务,其目的不仅是检测稀疏点云中的物体,还旨在为每个物体提供精确的掩码。
现代方法可被类分为两组:
- 基于假设的方式
- 基于聚类的方式
基于假设的方法会将三维物体分段,当作自上而下的管线。它们首先生成区域提案,然后判定这些区域内的物体掩码。然而,由于点云的稀疏性,这些方法往往会卡住。在三维空间中,边界框具有很高的自由度,这提升了近似的复杂性。此外,点通常仅存在于物体表面的某些部分,故难以定位几何中心。低品质的区域提案会影响基于模块的双分匹配,并进一步降低模型性能。
对比之下,基于聚类的方法遵循自下而上的管线。它们预测逐点语义标签与实例中心偏移量。然后,它们将漂移点和语义预测聚合到实例当中。无论如何,这些方法有其自身的局限性。它们对语义分段输出的依赖,可能会导致预测不准确。甚至,中间数据聚合步骤会增加训练和推理时间。
为了解决这些限制,并利用这两种方式的优势,《针对三维场景实例分段的超点变换器》的作者提出了一种新颖的端到端两阶段三维物体分段方法,称为 超点变换器(SPFormer)。SPFormer 将点云中自下而上的潜在物体分组为超点,并通过查询向量按自上而下风格提案实例。
在自下而上的分组阶段,稀疏 3D U-Net 用于提取点级特征。引入了一个简单的点池化层,来将点级对象候选者分组为超点。这些超点使用几何形态来表示同质相邻点。结果中潜在的对象剔除了通过间接语义和中心距离标签进行监督的需要。作者处置超点作为 3D 场景的潜在中间表示,并直接使用实例标签来训练模型。
在自上而下的提案阶段,引入了一个新的带有查询的 变换器 解码器。这些查询向量基于自上而下管线中的超点特征预测实例。可学习的查询向量经由超点交叉注意力来捕获实例信息。使用富含实例信息和超点特征的查询向量,解码器直接预测类标签、置信度分数、和实例掩码。配以基于超点掩码的双分匹配,SPFormer 无需劳力密集型聚合步骤,即可启用端到端训练。此外,SPFormer 无需后期处理,进一步改进了模型效率。
1. SPFormer 算法
正如作者所提议,SPFormer 模型的架构在逻辑上被划分为不同的模块。最初,采用稀疏 3D U-net 来提取自下而上的点级物体特征。假设输入点云包含 N 个点,则每个点都由 RGB 颜色值、和 XYZ 坐标表征。为了规范原始数据,作者提议对点云进行体素化,并用由稀疏卷积组成的 U-Net 式主干来提取表示为 P′.的点特征。与基于聚类的方法不同,所提议方式没有包含额外的语义分支。
为了形成一个统一的框架,SPFormer 的作者直接把已提取点特征 P′ 输入到基于预先计算点的超点池化层之中。该超点池化层通过每个超点内的点进行平均来得到 S 对象。值得注意的是,超点池化层可靠地缩小了原始点云的规模,显著降低了后续处理的计算成本,同时提升了模型的整体表现效率。
查询解码器由两个分支组成:实例 和 掩码。在掩码分支中,使用简单的多层感知器(MLP)来提取支持实例掩码 𝐒掩码 的特征。实例 分支包含一系列 变换器 解码器层。它们通过交叉注意超点来解码可学习查询向量。
假设有 K 个可学习查询向量。我们将每个 变换器 解码器层的查询向量属性预定义为 Zl。
鉴于超点的不规则性和大小可变,作者引入了一个 变换器 结构来处理输入数据中的这种可变性。超点特征和可学习的查询向量当作 变换器 解码器的输入。精心设计的改编版 变换器 解码器层的架构如下图所示。
SPFormer 中的查询向量在训练之前随机初始化,并且每个点云的实例特定信息仅经由超点交叉注意来获取。如是结果,与传统的 变换器 解码器相比,所提议变换器解码器层通过倒置自注意力和交叉注意力层的顺序来修改标准架构。进而,鉴于输入由超点特征组成,位置编码也被省略了。
为了通过超点 交叉注意捕获上下文信息,应用了注意力掩码 Aij,表示超点 j 对查询 i 的影响。根据掩码分支中预测的超点掩码 Ml,超点注意力掩码 Al 是使用 τ=0.5 的阈值滤波器计算的,该值由作者凭经验确定。
当 变换器 解码器层堆叠时,Superpoint Al 注意力掩码会动态地将交叉注意力限制为聚焦在前景实例区域。
使用自实体分支的查询向量 Zl,作者使用两个独立的 MLP 来预测每个查询向量的分类和品质分数。值得注意的是,添加了 “无物体” 预测,以便在双分匹配期间显式分配置信度分数,将所有不匹配的查询视为负样本。
甚至,由于提案排名会显著影响实例分段性能,并且由于一对一匹配制程,大多数提案都被视为背景,故可能会出现排名不一致的情况。为了缓解这种情况,作者引入了一个评分分支,用来估算每个超点掩码预测的品质,帮助纠正这样的偏差。
鉴于在基于 变换器 的架构中常见的缓慢收敛,作者将每个 变换器 解码器层的输出路由到一个共享的预测头中,以便生成提案。在训练期间,将落实的置信度分数分配给每个解码器层的输出。这种方式可提升模型性能,并允许查询向量在各层中更有效地演变。
在推理时刻,给定原始输入点云,SPFormer 直接预测 K 个物体实例,以及它们的类标签,和相应的超点掩码。最终掩码分数是通过对每个预测掩码中值大于 0.5 的超级点的概率求解平均值来得到的。SPFormer 在后期处理过程中不依赖于非最大值抑制,其致力于提高推理速度。
作者阐述的 SPFormer 架构的可视化表现如下所示。
2. 利用 MQL5 实现
在回顾了 SPFormer 方法的理论层面之后,我们现在进入本文的实践部分,在那里我们利用 MQL5 实现对所提议方法的解释。我必须要说,今天我们有很多工作要做。那好,我们开始吧。
2.1扩展 OpenCL 程序
我们首先升级现有的 OpenCL 程序。SPFormer 方法的作者提出一种基于预测对象掩码的新型掩码算法。关键思想是将每个查询仅与相关超点匹配。这与我们之前用过的原版变换器中所用的基于位置的方法非常不同。因此,我们必须为 交叉注意力 和反向传播开发新的内核。我们从实现前馈通验内核 MHMaskAttentionOut 开始,它在很大程度上借鉴了原版变换器内核。但我们将进行修改,以便适应新的掩码机制。
如以前的实现,内核将接受指向包含 查询、键 和 值 实体的全局缓冲区的指针,这些实体的数值是预先计算好的。此外,我们还包括指向注意力系数缓冲区、和输出结果缓冲区的指针。我们还引入了一个指向全局掩码缓冲区和掩码阈值参数的附加指针。
__kernel void MHMaskAttentionOut(__global const float *q, ///<[in] Matrix of Querys __global const float *kv, ///<[in] Matrix of Keys __global float *score, ///<[out] Matrix of Scores __global const float *mask, ///<[in] Mask Matrix __global float *out, ///<[out] Matrix of attention const int dimension, ///< Dimension of Key const int heads_kv, const float mask_level ) { //--- init const int q_id = get_global_id(0); const int k = get_global_id(1); const int h = get_global_id(2); const int qunits = get_global_size(0); const int kunits = get_global_size(1); const int heads = get_global_size(2);
如前,我们计划在三维任务空间(查询,键,头)中启动内核。我们将创建局部工作组,从而启用在相同 查询 内跨注意力头的线程之间交换数据。在方法主体中,我们立即识别任务空间中当前的操作流程,并定义任务空间的参数。
接下来,我们计算数据缓冲区中的偏移量,并将获得的数值保存在局部变量之中。
const int h_kv = h % heads_kv; const int shift_q = dimension * (q_id * heads + h); const int shift_k = dimension * (2 * heads_kv * k + h_kv); const int shift_v = dimension * (2 * heads_kv * k + heads_kv + h_kv); const int shift_s = kunits * (q_id * heads + h) + k;
然后,我们评估当前线程的相关注意力掩码,并准备其它辅助常量。
const bool b_mask = (mask[shift_s] < mask_level); const uint ls = min((uint)get_local_size(1), (uint)LOCAL_ARRAY_SIZE); float koef = sqrt((float)dimension); if(koef < 1) koef = 1;
现在,我们在局部内存中创建一个数组,用来在工作组的线程之间交换数据。
__local float temp[LOCAL_ARRAY_SIZE];
接下来,我们计算单一查询内依赖系数的指数值之和。为此,我们创建一个循环,迭代计算各个总和,并将其写入局部数据数组。
//--- sum of exp uint count = 0; if(k < ls) { temp[k] = 0; do { if(b_mask || q_id >= (count * ls + k)) if((count * ls) < (kunits - k)) { float sum = 0; int sh_k = 2 * dimension * heads_kv * count * ls; for(int d = 0; d < dimension; d++) sum = q[shift_q + d] * kv[shift_k + d + sh_k]; sum = exp(sum / koef); if(isnan(sum)) sum = 0; temp[k] = temp[k] + sum; } count++; } while((count * ls + k) < kunits); } barrier(CLK_LOCAL_MEM_FENCE);
然后我们将局部数据数组的所有值相加。
do { count = (count + 1) / 2; if(k < ls) temp[k] += (k < count && (k + count) < kunits ? temp[k + count] : 0); if(k + count < ls) temp[k + count] = 0; barrier(CLK_LOCAL_MEM_FENCE); } while(count > 1);
注意,在局部求和期间,数值计算时参考了掩码。现在我们可以计算注意力系数的归一化数值,同时参考掩码。
//--- score float sum = temp[0]; float sc = 0; if(b_mask || q_id >= (count * ls + k)) if(sum != 0) { for(int d = 0; d < dimension; d++) sc = q[shift_q + d] * kv[shift_k + d]; sc = exp(sc / koef) / sum; if(isnan(sc)) sc = 0; } score[shift_s] = sc; barrier(CLK_LOCAL_MEM_FENCE);
在计算注意力系数时,我们将掩码元素的数值清零。因此,我们现在可用原版算法来计算交叉注意力模块的结果。
for(int d = 0; d < dimension; d++) { uint count = 0; if(k < ls) do { if((count * ls) < (kunits - k)) { float sum = kv[shift_v + d] * (count == 0 ? sc : score[shift_s + count * ls]); if(isnan(sum)) sum = 0; temp[k] = (count > 0 ? temp[k] : 0) + sum; } count++; } while((count * ls + k) < kunits); barrier(CLK_LOCAL_MEM_FENCE); //--- count = min(ls, (uint)kunits); do { count = (count + 1) / 2; if(k < ls) temp[k] += (k < count && (k + count) < kunits ? temp[k + count] : 0); if(k + count < ls) temp[k + count] = 0; barrier(CLK_LOCAL_MEM_FENCE); } while(count > 1); //--- out[shift_q + d] = temp[0]; } }
反向传播内核 MHMaskAttentionInsideGradients 的升级范围较少。它可被称为逐点。前馈通验期间在该点将依赖系数归零,令我们能用原版算法将误差梯度分配给 查询、键 和 值 实体。然而,这令我们无法将误差梯度传播到掩码。故此,我们在原版算法中添加掩码调整梯度。
__kernel void MHMaskAttentionInsideGradients(__global const float *q, __global float *q_g, __global const float *kv, __global float *kv_g, __global const float *mask, __global float *mask_g, __global const float *scores, __global const float *gradient, const int kunits, const int heads_kv, const float mask_level ) { ........ ........ //--- Mask's gradient for(int k = q_id; k < kunits; k += qunits) { float m = mask[shift_s + k]; if(m < mask_level) mask_g[shift_s + k] = 0; else mask_g[shift_s + k] = 1 - m; } }
注意,相关的掩码条目被归一化为 “1”。对于不相关的掩码,误差梯度将清零,因为它们不会影响模型的输出。
据此,我们就完成了 OpenCL 内核的实现。您可在随附的文件中参考新内核的完整源代码。
2.2创建 SPFormer 方法类
完成 OpenCL 程序修改后,我们现在转到主程序。在此,我们创建一个新类 CNeuronSPFormer,它将从全连接层 CNeuronBaseOCL 继承核心功能。由于 SPFormer 所需调整的规模和特异性,我决定不继承以前实现的交叉注意力模块。新类的结构如下所示。新类结构如下所示。
class CNeuronSPFormer : public CNeuronBaseOCL { protected: uint iWindow; uint iUnits; uint iHeads; uint iSPWindow; uint iSPUnits; uint iSPHeads; uint iWindowKey; uint iLayers; uint iLayersSP; //--- CLayer cSuperPoints; CLayer cQuery; CLayer cSPKeyValue; CLayer cMask; CArrayInt cScores; CLayer cMHCrossAttentionOut; CLayer cCrossAttentionOut; CLayer cResidual; CLayer cQKeyValue; CLayer cMHSelfAttentionOut; CLayer cSelfAttentionOut; CLayer cFeedForward; CBufferFloat cTempSP; CBufferFloat cTempQ; CBufferFloat cTempSelfKV; CBufferFloat cTempCrossKV; //--- virtual bool CreateBuffers(void); virtual bool AttentionOut(CNeuronBaseOCL *q, CNeuronBaseOCL *kv, const int scores, CNeuronBaseOCL *out, CNeuronBaseOCL *mask, const int units, const int heads, const int units_kv, const int heads_kv, const int dimension, const float mask_level = 0.5f); virtual bool AttentionInsideGradients(CNeuronBaseOCL *q, CNeuronBaseOCL *kv, const int scores, CNeuronBaseOCL *out, CNeuronBaseOCL *mask, const int units, const int heads, const int units_kv, const int heads_kv, const int dimension, const float mask_level = 0.5f); //--- virtual bool feedForward(CNeuronBaseOCL *NeuronOCL) override; //--- virtual bool calcInputGradients(CNeuronBaseOCL *NeuronOCL) override; virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL) override; public: CNeuronSPFormer(void) {}; ~CNeuronSPFormer(void) {}; //--- virtual bool Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint window_key, uint units_count, uint heads, uint window_sp, uint units_sp, uint heads_sp, uint layers, uint layers_to_sp, ENUM_OPTIMIZATION optimization_type, uint batch); //--- virtual int Type(void) override const { return defNeuronSPFormer; } //--- virtual bool Save(int const file_handle) override; virtual bool Load(int const file_handle) override; //--- virtual bool WeightsUpdate(CNeuronBaseOCL *source, float tau) override; virtual void SetOpenCL(COpenCLMy *obj) override; };
所呈现的类结构包括大量的变量和嵌套对象,其中许多名称与我们之前在注意力相关的类实现中所用的名称一致。这并非巧合。在实现过程中,我们将领略所有对象的功能。
请注意,所有内部对象都声明为静态,这允许我们将构造函数和析构函数都保持为空。继承的成员和新声明的成员的初始化都是在 Init 方法中以独占方式执行。如您所知,Init 方法的参数包括显式定义创建对象架构的关键常量。
bool CNeuronSPFormer::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint window_key, uint units_count, uint heads, uint window_sp, uint units_sp, uint heads_sp, uint layers, uint layers_to_sp, ENUM_OPTIMIZATION optimization_type, uint batch) { if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, window * units_count, optimization_type, batch)) return false;
在方法主体中,我们立即调用父类的同名方法,于其中执行继承对象和变量的初始化。
之后,我们立即将获取的常量保存到类的内部变量之中。
iWindow = window; iUnits = units_count; iHeads = heads; iSPUnits = units_sp; iSPWindow = window_sp; iSPHeads = heads_sp; iWindowKey = window_key; iLayers = MathMax(layers, 1); iLayersSP = MathMax(layers_to_sp, 1);
在下一步中,我们初始化一个小型 MLP 来生成一个可学习查询的向量。
//--- Init Querys CNeuronBaseOCL *base = new CNeuronBaseOCL(); if(!base) return false; if(!base.Init(iWindow * iUnits, 0, OpenCL, 1, optimization, iBatch)) return false; CBufferFloat *buf = base.getOutput(); if(!buf || !buf.BufferInit(1, 1) || !buf.BufferWrite()) return false; if(!cQuery.Add(base)) return false; base = new CNeuronBaseOCL(); if(!base.Init(0, 1, OpenCL, iWindow * iUnits, optimization, iBatch)) return false; if(!cQuery.Add(base)) return false;
接下来,我们创建一个超点提取模块。在此,我们生成一个由 4 个连续神经层组成的模块,其架构适应原始序列的大小。如果下一层输入处的序列长度是 2 的倍数,那么我们使用带有残差连接的卷积模块,这将令序列的大小减小 2 倍。
//--- Init SuperPoints for(int r = 0; r < 4; r++) { if(iSPUnits % 2 == 0) { iSPUnits /= 2; CResidualConv *residual = new CResidualConv(); if(!residual) return false; if(!residual.Init(0, r+2, OpenCL, 2*iSPWindow, iSPWindow, iSPUnits, optimization, iBatch)) return false; if(!cSuperPoints.Add(residual)) return false; }
否则,我们使用一个简单的卷积层,以 1 个元素的步幅分析序列的 2 个相邻元素。因此,序列的长度减少了 1。
else { iSPUnits--; CNeuronConvOCL *conv = new CNeuronConvOCL(); if(!conv.Init(0, r+2, OpenCL, 2*iSPWindow, iSPWindow, iSPWindow, iSPUnits, 1, optimization, iBatch)) return false; if(!cSuperPoints.Add(conv)) return false; } }
我们已经初始化了数据预处理对象。接下来,我们继续初始化修改后的 变换器 解码器的内层。为此,我们创建局部变量来临时存储指向对象的指针,并组织一个循环,迭代次数等于解码器指定数量的内层数量。
CNeuronConvOCL *conv = NULL; CNeuronTransposeOCL *transp = NULL; for(uint l = 0; l < iLayers; l++) { //--- Cross Attention //--- Query conv = new CNeuronConvOCL(); if(!conv) return false; if(!conv.Init(0, l * 14 + 6, OpenCL, iWindow, iWindow, iWindowKey * iHeads, iUnits, 1, optimization, iBatch)) return false; if(!cQuery.Add(conv)) return false; //--- Key-Value if(l % iLayersSP == 0) { conv = new CNeuronConvOCL(); if(!conv) return false; if(!conv.Init(0, l * 14 + 7, OpenCL, iSPWindow, iSPWindow, iWindowKey * iSPHeads, iSPUnits, 1, optimization, iBatch)) return false; if(!cSPKeyValue.Add(conv)) return false; }
在此,我们首先初始化生成 查询、键 和 数值 实体的内层。键-值张量仅在需要时生成。
此处,我们还添加了一个掩码生成层。为此,我们将使用一个卷积层,该层将为超点序列的每个单独元素的所有查询生成掩码系数。鉴于我们采用多头注意力算法,我们还将为每个注意力头生成系数。为了将这些数值归一化,我们采用 sigmoid 激活函数。
//--- Mask conv = new CNeuronConvOCL(); if(!conv) return false; if(!conv.Init(0, l * 14 + 8, OpenCL, iSPWindow, iSPWindow, iUnits * iHeads, iSPUnits, 1, optimization, iBatch)) return false; conv.SetActivationFunction(SIGMOID); if(!cMask.Add(conv)) return false;
此处应当注意,在执行交叉注意力时,我们将需要超点查询的注意力系数。因此,我们针对得到的掩码张量执行转置。
transp = new CNeuronTransposeOCL(); if(!transp) return false; if(!transp.Init(0, l * 14 + 9, OpenCL, iSPUnits, iUnits * iHeads, optimization, iBatch)) return false; if(!cMask.Add(transp)) return false;
下一步是准备对象,以便记录交叉注意力的结果。我们从多头注意力开始。
//--- MH Cross Attention out base = new CNeuronBaseOCL(); if(!base) return false; if(!base.Init(0, l * 14 + 10, OpenCL, iWindow * iUnits * iHeads, optimization, iBatch)) return false; if(!cMHCrossAttentionOut.Add(base)) return false;
然后我们针对压缩表示同样行事。
//--- Cross Attention out conv = new CNeuronConvOCL(); if(!conv) return false; if(!conv.Init(0, l * 14 + 11, OpenCL, iWindow * iHeads, iWindow * iHeads, iWindow, iUnits, 1, optimization, iBatch)) return false; if(!cCrossAttentionOut.Add(conv)) return false;
接下来,我们添加一个汇总原始数据的层。
//--- Residual base = new CNeuronBaseOCL(); if(!base) return false; if(!base.Init(0, l * 14 + 12, OpenCL, iWindow * iUnits, optimization, iBatch)) return false; if(!cResidual.Add(base)) return false;
接下来是 自注意力 模块。此处我们还生成了 查询、键 和 值 实体,但这次我们用的是交叉注意力的结果。
//--- Self-Attention //--- Query conv = new CNeuronConvOCL(); if(!conv) return false; if(!conv.Init(0, l*14+13, OpenCL, iWindow, iWindow, iWindowKey * iHeads, iUnits, 1, optimization, iBatch)) return false; if(!cQuery.Add(conv)) return false; //--- Key-Value if(l % iLayersSP == 0) { conv = new CNeuronConvOCL(); if(!conv) return false; if(!conv.Init(0, l*14+14, OpenCL, iWindow, iWindow, iWindowKey * iSPHeads, iUnits, 1, optimization, iBatch)) return false; if(!cQKeyValue.Add(conv)) return false; }
然后我们添加对象来记录多头注意力和已压缩数值的结果。
//--- MH Attention out base = new CNeuronBaseOCL(); if(!base) return false; if(!base.Init(0, l * 14 + 15, OpenCL, iWindow * iUnits * iHeads, optimization, iBatch)) return false; if(!cMHSelfAttentionOut.Add(base)) return false; //--- Attention out conv = new CNeuronConvOCL(); if(!conv) return false; if(!conv.Init(0, l * 14 + 16, OpenCL, iWindow * iHeads, iWindow * iHeads, iWindow, iUnits, 1, optimization, iBatch)) return false; if(!cSelfAttentionOut.Add(conv)) return false;
添加一个层来汇总其与交叉注意力结果。
//--- Residual base = new CNeuronBaseOCL(); if(!base) return false; if(!base.Init(0, l * 14 + 17, OpenCL, iWindow * iUnits, optimization, iBatch)) return false; if(!cResidual.Add(base)) return false;
然后添加一个搭配残差连接的前馈模块。
//--- FeedForward conv = new CNeuronConvOCL(); if(!conv) return false; if(!conv.Init(0, l * 14 + 18, OpenCL, iWindow, iWindow, iWindow * 4, iUnits, 1, optimization, iBatch)) return false; conv.SetActivationFunction(LReLU); if(!cFeedForward.Add(conv)) return false; conv = new CNeuronConvOCL(); if(!conv) return false; if(!conv.Init(0, l * 14 + 19, OpenCL, iWindow * 4, iWindow * 4, iWindow, iUnits, 1, optimization, iBatch)) return false; if(!cFeedForward.Add(conv)) return false; //--- Residual base = new CNeuronBaseOCL(); if(!base) return false; if(!base.Init(0, l * 14 + 20, OpenCL, iWindow * iUnits, optimization, iBatch)) return false; if(!cResidual.Add(base)) return false; if(!base.SetGradient(conv.getGradient())) return false;
注意,为了避免不必要的数据复制操作,我们将前馈模块最后一层的误差梯度缓冲区与残差连接层组合在一起。我们针对结果缓冲区、和最后一个内层中的上层误差梯度执行类似的操作。
if(l == (iLayers - 1)) { if(!SetGradient(conv.getGradient())) return false; if(!SetOutput(base.getOutput())) return false; } }
应当注意的是,在对象初始化过程中,我们并未创建注意力系数数据的缓冲区。我们已将它们的创建、和内部对象的初始化移到一个单独的方法之中。
//--- SetOpenCL(OpenCL); //--- return true; }
初始化内部对象后,我们转到构造前馈通验方法。调用上述所创建内核的方法算法,我们留待独立研究。它们没有什么特别的新意。我们只详细讨论顶级 feedForward 方法的算法,在其中,我们将构建 SPFormer 算法的清晰操作序列。
bool CNeuronSPFormer::feedForward(CNeuronBaseOCL *NeuronOCL) { CNeuronBaseOCL *superpoints = NeuronOCL; CNeuronBaseOCL *neuron = NULL, *inputs = NULL, *q = NULL, *kv_cross = NULL, *kv_self = NULL;
在方法参数中,我们接收指向源数据对象的指针。在方法主体中,我们声明了许多局部变量,临时存储指向对象的指针。
接下来,我们通过超点提取模型运行生成的原始数据。
//--- Superpoints for(int l = 0; l < cSuperPoints.Total(); l++) { neuron = cSuperPoints[l]; if(!neuron || !neuron.FeedForward(superpoints)) return false; superpoints = neuron; }
然后我们生成一个查询向量。
//--- Query neuron = cQuery[1]; if(!neuron || !neuron.FeedForward(cQuery[0])) return false;
准备工作完毕。我们创建一个循环来迭代遍历解码器的内部神经层。
inputs = neuron; for(uint l = 0; l < iLayers; l++) { //--- Cross Attentionn q = cQuery[l * 2 + 2]; if(!q || !q.FeedForward(inputs)) return false; if((l % iLayersSP) == 0) { kv_cross = cSPKeyValue[l / iLayersSP]; if(!kv_cross || !kv_cross.FeedForward(superpoints)) return false; }
此处,我们首先准备 查询、键 和 值 实体。
我们生成掩码。
neuron = cMask[l * 2]; if(!neuron || !neuron.FeedForward(superpoints)) return false; neuron = cMask[l * 2 + 1]; if(!neuron || !neuron.FeedForward(cMask[l * 2])) return false;
然后,我们参考掩码执行交叉注意力算法。
if(!AttentionOut(q, kv_cross, cScores[l * 2], cMHCrossAttentionOut[l], neuron, iUnits, iHeads, iSPUnits, iSPHeads, iWindowKey)) return false;
我们将把多头注意力结果降低到查询张量的大小。
neuron = cCrossAttentionOut[l]; if(!neuron || !neuron.FeedForward(cMHCrossAttentionOut[l])) return false;
之后,我们汇总来自两个信息流的数据,并归一化。
q = inputs; inputs = cResidual[l * 3]; if(!inputs || !SumAndNormilize(q.getOutput(), neuron.getOutput(), inputs.getOutput(), iWindow, true, 0, 0, 0, 1)) return false;
交叉注意力 模块后跟 自注意力 算法。此处,我们再次生成 查询、键 和 值 实体,但已基于交叉注意力的结果。
//--- Self-Attention q = cQuery[l * 2 + 3]; if(!q || !q.FeedForward(inputs)) return false; if((l % iLayersSP) == 0) { kv_self = cQKeyValue[l / iLayersSP]; if(!kv_self || !kv_self.FeedForward(inputs)) return false; }
在该阶段,我们未用到掩码。因此,在调用注意力方法时,我们指定 NULL,而非掩码对象。
if(!AttentionOut(q, kv_self, cScores[l * 2 + 1], cMHSelfAttentionOut[l], NULL, iUnits, iHeads, iUnits, iHeads, iWindowKey)) return false;
我们将多头注意力结果降低到查询张量大小的级别。
neuron = cSelfAttentionOut[l]; if(!neuron || !neuron.FeedForward(cMHSelfAttentionOut[l])) return false;
然后,我们将其与交叉注意力结果的向量相加,并归一化数据。
q = inputs; inputs = cResidual[l * 3 + 1]; if(!inputs || !SumAndNormilize(q.getOutput(), neuron.getOutput(), inputs.getOutput(), iWindow, true, 0, 0, 0, 1)) return false;
接下来,与 原版变换器类似,我们经由 FeedForward 模块传播数据。之后,我们转到遍历内层循环的下一次迭代。
//--- FeedForward neuron = cFeedForward[l * 2]; if(!neuron || !neuron.FeedForward(inputs)) return false; neuron = cFeedForward[l * 2 + 1]; if(!neuron || !neuron.FeedForward(cFeedForward[l * 2])) return false; q = inputs; inputs = cResidual[l * 3 + 2]; if(!inputs || !SumAndNormilize(q.getOutput(), neuron.getOutput(), inputs.getOutput(), iWindow, true, 0, 0, 0, 1)) return false; } //--- return true; }
注意,在进入循环的下一次迭代之前,我们在输入变量中保存一个指向当前内层最后一个对象的指针。
解码器内层循环的所有迭代成功完成后,我们将方法操作的布尔结果返回给调用程序。
下一步我们要做的是构建反向传播通验方法。特别有趣的是,负责基于误差梯度对整体输出的贡献,将误差梯度分派给模型所有元素的方法:calcInputGradients。
bool CNeuronSPFormer::calcInputGradients(CNeuronBaseOCL *NeuronOCL) { if(!NeuronOCL) return false;
该方法接收指向之前神经层对象的指针,其在前馈通验期间提供输入数据。现在,目标是按其输入对模型输出的影响,将误差梯度成正比传播回该层。
在方法主体中,我们首先验证收到的指针,因为继续配以无效的引用,令所有后续操作变得毫无意义。
然后,我们声明一组局部变量,临时存储指向梯度计算过程中用到的对象指针。
CNeuronBaseOCL *superpoints = cSuperPoints[cSuperPoints.Total() - 1]; CNeuronBaseOCL *neuron = NULL, *inputs = NULL, *q = NULL, *kv_cross = cSPKeyValue[cSPKeyValue.Total() - 1], *kv_self = cQKeyValue[cQKeyValue.Total() - 1];
我们重置缓冲区,以便临时存储中间数据。
if(!cTempSP.Fill(0) || !cTempSelfKV.Fill(0) || !cTempCrossKV.Fill(0)) return false;
然后我们组织一个逆向循环遍历解码器的内层。
for(int l = int(iLayers - 1); l >= 0; l--) { //--- FeedForward neuron = cFeedForward[l * 2]; if(!neuron || !neuron.calcHiddenGradients(cFeedForward[l * 2 + 1])) return false;
您或许还记得,在类对象的初始化过程中,我们将指向上层误差梯度缓冲区、和残差连接层的指针,替换为指向 FeedForward 模块最后一层的指针。这样设计允许我们直接从 FeedForward 模块开始反向传播,无需手动将误差梯度从上层缓冲区和残余连接层传递到 FeedForward 的最后一层。
随后,我们将误差梯度向下传播到 自注意力 模块的残差连接层。
neuron = cResidual[l * 3 + 1]; if(!neuron || !neuron.calcHiddenGradients(cFeedForward[l * 2])) return false;
之后,我们将两个数据流的误差梯度相加,并将其传递给 自注意力 结果层。
if(!SumAndNormilize(((CNeuronBaseOCL*)cResidual[l * 3 + 2]).getGradient(), neuron.getGradient(), ((CNeuronBaseOCL*)cSelfAttentionOut[l]).getGradient(), iWindow, false, 0, 0, 0, 1)) return false;
然后,我们沿注意力头分派获得的误差梯度。
//--- Self-Attention neuron = cMHSelfAttentionOut[l]; if(!neuron || !neuron.calcHiddenGradients(cSelfAttentionOut[l])) return false;
我们获得指向 自注意力 模块的 查询、键 和 值 实体缓冲区的指针。如有必要,我们重置缓冲区,以便累积中间值。
q = cQuery[l * 2 + 3]; if(((l + 1) % iLayersSP) == 0) { kv_self = cQKeyValue[l / iLayersSP]; if(!kv_self || !cTempSelfKV.Fill(0)) return false; }
然后,我们根据模型性能结果的影响,将误差梯度传递给它们。
if(!AttentionInsideGradients(q, kv_self, cScores[l * 2 + 1], neuron, NULL, iUnits, iHeads, iUnits, iHeads, iWindowKey)) return false;
我们已提供了将一个 键-值 张量用于解码器的若干多个内层的可能性。因此,根据当前内层索引,我们将获得的值与先前累积的误差梯度相加,放入临时数据累积缓冲区、或相应 键-值 层的梯度缓冲区之中。
if(iLayersSP > 1) { if((l % iLayersSP) == 0) { if(!SumAndNormilize(kv_self.getGradient(), GetPointer(cTempSelfKV), kv_self.getGradient(), iWindowKey, false, 0, 0, 0, 1)) return false; } else { if(!SumAndNormilize(kv_self.getGradient(), GetPointer(cTempSelfKV), GetPointer(cTempSelfKV), iWindowKey, false, 0, 0, 0, 1)) return false; } }
然后我们将误差梯度向下传播到交叉注意力模块的残差连接层。于此,我们首先传递查询实体中的误差梯度。
inputs = cResidual[l * 3]; if(!inputs || !inputs.calcHiddenGradients(q, NULL)) return false;
然后,如有必要,我们添加 键-值 信息流中的误差梯度。
if((l % iLayersSP) == 0) { CBufferFloat *temp = inputs.getGradient(); if(!inputs.SetGradient(GetPointer(cTempQ), false)) return false; if(!inputs.calcHiddenGradients(kv_self, NULL)) return false; if(!SumAndNormilize(temp, GetPointer(cTempQ), temp, iWindow, false, 0, 0, 0, 1)) return false; if(!inputs.SetGradient(temp, false)) return false; }
接下来,我们加上来自 自注意力 模块残差流的误差梯度,并将接收到的值传递给交叉注意力模块。
if(!SumAndNormilize(((CNeuronBaseOCL*)cSelfAttentionOut[l]).getGradient(), inputs.getGradient(), ((CNeuronBaseOCL*)cCrossAttentionOut[l]).getGradient(), iWindow, false, 0, 0, 0, 1)) return false;
之后,我们需要通过交叉注意力模块传播误差梯度。首先,我们在注意力头之间分派误差梯度。
//--- Cross Attention neuron = cMHCrossAttentionOut[l]; if(!neuron || !neuron.calcHiddenGradients(cCrossAttentionOut[l])) return false;
与 自注意力 一样,我们获得指向 查询、键 和 值 实体对象的指针。
q = cQuery[l * 2 + 2]; if(((l + 1) % iLayersSP) == 0) { kv_cross = cSPKeyValue[l / iLayersSP]; if(!kv_cross || !cTempCrossKV.Fill(0)) return false; }
然后我们通过注意力模块传播误差梯度。不过,在本例中,我们添加指向掩码对象的指针。
if(!AttentionInsideGradients(q, kv_cross, cScores[l * 2], neuron, cMask[l * 2 + 1], iUnits, iHeads, iSPUnits, iSPHeads, iWindowKey)) return false;
来自 查询 实体的误差梯度将传递到前一个解码器层、或查询向量。对象的选择取决于当前解码器层。
inputs = (l == 0 ? cQuery[1] : cResidual[l * 3 - 1]); if(!inputs.calcHiddenGradients(q, NULL)) return false;
在此,我们加上沿残差连接信息流的误差梯度。
if(!SumAndNormilize(inputs.getGradient(), ((CNeuronBaseOCL*)cCrossAttentionOut[l]).getGradient(), inputs.getGradient(), iWindow, false, 0, 0, 0, 1)) return false;
在该阶段,我们已经完成了沿查询向量通路的梯度传播。然而,我们仍然需要经由超点通路反向传播误差梯度。为此,我们首先检查是否有必要从 键-值 张量传播梯度。若是,则计算出的梯度也会被累积到包含先前累积的误差梯度的缓冲区之中。
if((l % iLayersSP) == 0) { if(!superpoints.calcHiddenGradients(kv_cross, NULL)) return false; if(!SumAndNormilize(superpoints.getGradient(), GetPointer(cTempSP), GetPointer(cTempSP), iSPWindow, false, 0, 0, 0, 1)) return false; }
然后,我们分派来自掩码生成模型的误差梯度。
neuron = cMask[l * 2]; if(!neuron || !neuron.calcHiddenGradients(cMask[l * 2 + 1]) || !DeActivation(neuron.getOutput(), neuron.getGradient(), neuron.getGradient(), neuron.Activation())) return false; if(!superpoints.calcHiddenGradients(neuron, NULL)) return false;
我们还把获得的值添加到先前累积的误差梯度之中。请注意当前解码器层。
if(l == 0) { if(!SumAndNormilize(superpoints.getGradient(), GetPointer(cTempSP), superpoints.getGradient(), iSPWindow, false, 0, 0, 0, 1)) return false; } else if(!SumAndNormilize(superpoints.getGradient(), GetPointer(cTempSP), GetPointer(cTempSP), iSPWindow, false, 0, 0, 0, 1)) return false; }
在分析第一个解码器层(对应于我们实现中循环的最后一次迭代)的情况下,总梯度存储在超点模型最后一层的缓冲区之中。否则,出于中间存储,我们在临时缓冲区中累积误差梯度。
然后,我们继续覆盖解码器内层的逆向循环的下一次迭代。
一旦误差梯度成功传播到 变换器 解码器的所有内层,最后一步就是在超点模型的各层中分派梯度。鉴于超点模型具有线性结构,我们可以简单地组织一个覆盖其各层的反向迭代循环。
for(int l = cSuperPoints.Total() - 2; l >= 0; l--) { superpoints = cSuperPoints[l]; if(!superpoints || !superpoints.calcHiddenGradients(cSuperPoints[l + 1])) return false; }
在方法作结束时,我们将误差梯度从 超点 模型传递到源数据层,并将方法操作的执行逻辑结果返回给调用程序。
if(!NeuronOCL.calcHiddenGradients(superpoints, NULL)) return false; //--- return true; }
在该阶段,我们已实现了根据误差梯度对模型整体性能的影响,在所有内部组件和输入数据中传播误差梯度的过程。下一步是优化模型的可训练参数,以便把总体误差最小化。这些操作在 updateInputWeights 方法中执行。
重点要注意,模型的所有可训练参数都存储在我们的类内部对象当中。这些参数的优化算法已经在这些对象中实现。因此,在参数更新方法的范畴内,依次调用嵌套对象对应的方法就足够了。我鼓励您独立复习该方法的实现。提醒一下,新类及其所有组件的完整源代码都已在随附的素材中提供。
可训练模型的架构,以及用于训练和环境互动的所有支持程序完全继承自以前的工作。仅对编码器架构进行了细微的调整。我仍建议您独立探索。附件中包含本文开发中用到的所有类、和实用程序的完整代码。现在,我们转入工作的最后阶段:训练和测试模型。
3. 测试
在本文中,我们已完成了大量工作,实现了我们对 SPFormer 方法中所提议方法的解释。现在,我们转入模型训练和测试阶段,在该阶段,我们将根据真实历史数据评估参与者策略。
为了训练模型,我们依据 EURUSD 金融产品整个 2023 年度的真实历史数据,以及 H1 时间帧。所有指标参数均按其默认值设置。
训练算法继承自以前发表的文章,延及用于训练和评估的支持程序。
训练后的参与者政策略在 MetaTrader 5 策略测试器中进行了测试,依据 2024 年 1 月的真实历史数据,所有其它参数保持不变。测试结果呈现如下。
在测试期间,该模型进行了 54 笔交易,其中 26 笔以盈利了结。这占所有运作的 48%。平均盈利交易比无盈利操作的类似指标高 2 倍。这令该模型在测试期间获利。
然而,重点要指出,测试期间有限的交易数量,并不能为评估模型的长期可靠性和性能提供足够的基础。
结束语
SPFormer 方法展示了在交易应用中的适应性,特别是在市场数据的分段和市场信号的预测方面。与严重依赖中间步骤,并且通常对数据中的噪声敏感的传统模型不同,这种方式可直接依据市场信息的超点表示进行操作。使用变换器架构来预测市场形态,可以简化处理、提高预测准确性,并加快交易场景中的决策速度。
本文的实践部分阐述了我们利用 MQL5 实现所提议概念。我们根据这些概念训练模型,并采用真实历史数据测试其有效性。测试结果证明了该模型产生利润的能力,表明在现实世界中具有广阔的应用潜力。不过,此处提供的实现仅用于演示目的。在实时交易环境中部署模型之前,必须进行较长时间的扩展训练,以及彻底的验证和测试,以确其保稳健性和可靠性。
参考 文章中所用程序
# | 名称 | 类型 | 说明 |
---|---|---|---|
1 | Research.mq5 | 智能系统 | 收集样本的 EA |
2 | ResearchRealORL.mq5 | 智能系统 | 利用 Real ORL方法收集样本的 EA |
3 | Study.mq5 | 智能系统 | 模型训练 EA |
4 | Test.mq5 | 智能系统 | 模型测试 EA |
5 | Trajectory.mqh | 类库 | 系统状态描述结构 |
6 | NeuroNet.mqh | 类库 | 创建神经网络的类库 |
7 | NeuroNet.cl | 函数库 | OpenCL 程序代码库 |
本文由MetaQuotes Ltd译自俄文
原文地址: https://www.mql5.com/ru/articles/15928



伙计,这非常有趣,但对我来说非常高深!
感谢您的分享,让我一步步地学习。