
交易中的神经网络:受控分段(终章)
概述
在上一篇文章中,我们探讨了 RefMask3D 方法,旨在全面分析多模态互动,并了解所研究点云的特征。RefMask3D 是一个综合框架,包括若干模块:
- 含有集成几何-强化群-单词注意力模块的点编码器。该模块在特征编码的每个阶段,于对象自然语言描述和局部点群(子云)之间执行交叉模态注意力。作者提议的模块架构减少了点和单词之间直接关联中固有的噪声影响,同时将内部几何关系转化为精致的点云结构。这显著增强了模型与语言和几何数据动动的能力。
- 一种语言模型,它将目标对象的文本型描述转换为令牌结构,由模型来标识对象。
- 一组可训练的语言基元(语言原语构造 — LPC),旨在表示各种语义属性,如形状、颜色、大小、关系、位置、等等。当与特定的语言输入交互时,这些基元会获取相应的属性。
- 基于变换器的解码器,可增强模型对点云内多元化语义信息的专注,从而显著提高其准确定位和识别目标对象的能力。
- 对象聚类模块 — OCM 收集整体信息,并生成对象嵌入。
在上一篇文章中,我们已完成了框架实现的很大一部分。具体来说,我们在它们的各自类中实现了 几何-强化群-单词注意力 和 语言原语构造 模块。我们还注意到,解码器功能可用各种交叉注意力模块的现有实现来覆盖。我们之前停止了对象聚类模块的算法开发。而这就是我们要继续工作的所在。
1. 对象聚类模块的实现
如早前所述,对象聚类模块旨在聚合整体信息,并生成对象嵌入。下面提供了该模块的原始可视化效果。
从可视化中可见,对象聚类模块由两个自注意力模块、一个位于它们之间的交叉注意力模块、和一个输出端的 FFN 模块组成,作为一个完全连接 MLP 实现。该架构或许会引发不同的连锁反应。一方面,它类似于一个原版的 变换器 解码器,在 交叉注意力 之后放置了一个额外的 自注意力 模块。不过,应注意交叉注意力模块经修改后的功能。在该境况下,SPFormer 方法浮现在我脑海。若如此解释,第一个 自注意力 模块当作点表示的特征提取模块。
也就是说,所阐述架构方案或许也被视为原版变换器的紧凑版本。它拥有一个 “修剪的” 编码器,省略了 FeedForward 模块,以及一个解码器,其中重新排列了 交叉注意力 和 自注意力 模块。这种结构无疑令该模块成为整个 RefMask3D 框架的复杂且不可或缺的组成部分,其重要性已由作者提供的实验结果确认。协同对象聚类模块将模型性能提升 1.57%。
该模块接收来自两个源的输入。首先,例子解码器的输出,其中包括富含所分析点云信息的原始嵌入,经由初始 自注意力 模块通验,当作后续交叉注意力模块的上下文。交叉注意力模块的主要信息源是目标对象文本型描述的嵌入。这些嵌入用于形成交叉注意力模块的 查询 组件。交叉注意力 模块的输出被输入到第二个 自注意力 和 前馈 之中。
上述算法在 CNeuronOCM 类中实现,其结构勾勒如下。
class CNeuronOCM : public CNeuronBaseOCL { protected: uint iPrimWindow; uint iPrimUnits; uint iPrimHeads; uint iContWindow; uint iContUnits; uint iContHeads; uint iWindowKey; //--- CLayer cQuery; CLayer cKey; CLayer cValue; CLayer cMHAttentionOut; CLayer cAttentionOut; CArrayInt cScores; CLayer cResidual; CLayer cFeedForward; //--- virtual bool CreateBuffers(void); virtual bool AttentionOut(CNeuronBaseOCL *q, CNeuronBaseOCL *k, CNeuronBaseOCL *v, const int scores, CNeuronBaseOCL *out, const int units, const int heads, const int units_kv, const int heads_kv, const int dimension); virtual bool AttentionInsideGradients(CNeuronBaseOCL *q, CNeuronBaseOCL *k, CNeuronBaseOCL *v, const int scores, CNeuronBaseOCL *out, const int units, const int heads, const int units_kv, const int heads_kv, const int dimension); //--- virtual bool feedForward(CNeuronBaseOCL *NeuronOCL) override { return false; } virtual bool calcInputGradients(CNeuronBaseOCL *NeuronOCL) override { return false; } virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL) override { return false; } public: CNeuronOCM(void) {}; ~CNeuronOCM(void) {}; //--- virtual bool Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint prim_window, uint window_key, uint prim_units, uint prim_heads, uint cont_window, uint cont_units, uint cont_heads, ENUM_OPTIMIZATION optimization_type, uint batch); //--- virtual int Type(void) override const { return defNeuronOCM; } //--- 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; //--- virtual bool feedForward(CNeuronBaseOCL *Primitives, CNeuronBaseOCL *Context); virtual bool calcInputGradients(CNeuronBaseOCL *Primitives, CNeuronBaseOCL *Context); virtual bool updateInputWeights(CNeuronBaseOCL *Primitives, CNeuronBaseOCL *Context); //--- virtual uint GetPrimitiveWindow(void) const { return iPrimWindow; } virtual uint GetContextWindow(void) const { return iContWindow; } };
神经层的核心功能将继承自完全连接 CNeuronBaseOCL,我们将将其当作父类。
在前面讲述的新类结构中,我们可观察到一组熟悉的重写方法,以及一定数量的内部对象和变量声明。我们将在方法实现过程中详讨它们的功能。至于现在,重点是注意所有内部对象都被声明为静态。这意味着我们可将类构造和析构函数留空。这些声明和继承的内部对象均在 Init 方法中执行初始化。如您所知,在指定方法的参数中,我们会收到一组常量,这些常量允许我们明确解释所创建对象的架构。
bool CNeuronOCM::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint prim_window, uint window_key, uint prim_units, uint prim_heads, uint cont_window, uint cont_units, uint cont_heads, ENUM_OPTIMIZATION optimization_type, uint batch) { if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, cont_window * cont_units, optimization_type, batch)) return false;
在方法主体中,我们首先调用父类中的同名方法。该方法已实现了对接收参数进行最低要求的验证,及继承对象的初始化算法。我们通过检查父类方法返回的布尔值来监控方法执行成功与否。
父类方法成功执行之后,我们继续将接收到的常量值存储在类的内部变量当中。
iPrimWindow = prim_window; iPrimUnits = prim_units; iPrimHeads = prim_heads; iContWindow = cont_window; iContUnits = cont_units; iContHeads = cont_heads; iWindowKey = window_key;
接下来,我们清除与内部对象关联的动态数组。
cQuery.Clear(); cKey.Clear(); cValue.Clear(); cMHAttentionOut.Clear(); cAttentionOut.Clear(); cResidual.Clear(); cFeedForward.Clear();
然后我们转到初始化内部模块的组件。根据前面描述的算法,第一个要初始化的是负责分析原语之间依赖关系的 自注意力 模块。
在这一点上,值得回顾的是,该模块的输入由基元组成,这些基元在解码器内丰富了所分析点云的有关信息。因此,这个自注意力模块的任务是识别与给定点云相关的基元。
我们首先创建 查询、键 和 值 生成器对象。对于所有三个实体的生成,我们采用具有相同参数的卷积层。指向这些初始化对象的指针会被添加到根据生成的实体命名的动态数组当中。
CNeuronBaseOCL *neuron = NULL; CNeuronConvOCL *conv = NULL; //--- Primitives Self-Attention //--- Query conv = new CNeuronConvOCL(); if(!conv || !conv.Init(0, 0, OpenCL, iPrimWindow, iPrimWindow, iPrimHeads * iWindowKey, iPrimUnits, 1, optimization, iBatch) || !cQuery.Add(conv) ) return false; //--- Key conv = new CNeuronConvOCL(); if(!conv || !conv.Init(0, 1, OpenCL, iPrimWindow, iPrimWindow, iPrimHeads * iWindowKey, iPrimUnits, 1, optimization, iBatch) || !cKey.Add(conv) ) return false; //--- Value conv = new CNeuronConvOCL(); if(!conv || !conv.Init(0, 2, OpenCL, iPrimWindow, iPrimWindow, iPrimHeads * iWindowKey, iPrimUnits, 1, optimization, iBatch) || !cValue.Add(conv) ) return false;
接下来,我们添加一个全连接层来记录多头注意力的输出。
//--- Multi-Heads Attention Out neuron = new CNeuronBaseOCL(); if(!neuron || !neuron.Init(0, 3, OpenCL, iPrimHeads * iWindowKey * iPrimUnits, optimization, iBatch) || !cMHAttentionOut.Add(neuron) ) return false;
我们用卷积层把多头注意力的结果伸缩到原始数据张量的大小。
//--- Attention Out conv = new CNeuronConvOCL(); if(!conv || !conv.Init(0, 4, OpenCL, iPrimHeads * iWindowKey, iPrimHeads * iWindowKey, iPrimWindow, iPrimUnits, 1, optimization, iBatch) || !cAttentionOut.Add(conv) ) return false;
自注意力 模块中的最后一个是全连接残差连接层。
//--- Residual neuron = new CNeuronBaseOCL(); if(!neuron || !neuron.Init(0, 5, OpenCL, conv.Neurons(), optimization, iBatch) || !cResidual.Add(neuron) ) return false;
您可看出,上面讲述的注意力模块对象的结构是通用的。它可用于 自注意力 和 交叉注意力 模块两者。因此,为了实现后续 交叉注意力 模块的算法,我们将创建类似的对象,并将指向它们的指针添加到相同的动态数组当中。唯一的区别在于生成 查询、键 和 值 实体的数据源。生成 查询 实体时,我们使用上下文信息作为输入。
//--- Cross-Attention //--- Query conv = new CNeuronConvOCL(); if(!conv || !conv.Init(0, 6, OpenCL, iContWindow, iContWindow, iContHeads * iWindowKey, iContUnits, 1, optimization, iBatch) || !cQuery.Add(conv) ) return false;
为了生成 键 和 值 实体,我们用到上一个 自注意力 数据模块的输出。在此,我们有一个与可学习基元相同的张量大小。
//--- Key conv = new CNeuronConvOCL(); if(!conv || !conv.Init(0, 7, OpenCL, iPrimWindow, iPrimWindow, iPrimHeads * iWindowKey, iPrimUnits, 1, optimization, iBatch) || !cKey.Add(conv) ) return false; //--- Value conv = new CNeuronConvOCL(); if(!conv || !conv.Init(0, 8, OpenCL, iPrimWindow, iPrimWindow, iPrimHeads * iWindowKey, iPrimUnits, 1, optimization, iBatch) || !cValue.Add(conv) ) return false;
然后我们添加一个多头注意力结果层。
//--- Multi-Heads Cross-Attention Out neuron = new CNeuronBaseOCL(); if(!neuron || !neuron.Init(0, 9, OpenCL, iContHeads * iWindowKey * iContUnits, optimization, iBatch) || !cMHAttentionOut.Add(neuron) ) return false;
添加卷积缩放层。
//--- Cross-Attention Out conv = new CNeuronConvOCL(); if(!conv || !conv.Init(0, 10, OpenCL, iContHeads * iWindowKey, iContHeads * iWindowKey, iContWindow, iContUnits, 1, optimization, iBatch) || !cAttentionOut.Add(conv) ) return false;
交叉注意力 模块由残差连接层完成。
//--- Residual neuron = new CNeuronBaseOCL(); if(!neuron || !neuron.Init(0, 11, OpenCL, conv.Neurons(), optimization, iBatch) || !cResidual.Add(neuron) ) return false;
在下一步中,我们创建一个额外的 自注意力 模块。这个将用于分析上下文依赖关系。我们再次重复创建相应注意力相关对象的过程,将指向这些新创建对象的指针添加到之前所用的相同动态数组当中。不过,在这种情况下,所有实体都是根据交叉注意力模块的输出生成的。由此,输入张量现在具有所分析上下文的维度。
//--- Context Self-Attention //--- Query conv = new CNeuronConvOCL(); if(!conv || !conv.Init(0, 12, OpenCL, iContWindow, iContWindow, iContHeads * iWindowKey, iContUnits, 1, optimization, iBatch) || !cQuery.Add(conv) ) return false; //--- Key conv = new CNeuronConvOCL(); if(!conv || !conv.Init(0, 13, OpenCL, iContWindow, iContWindow, iContHeads * iWindowKey, iContUnits, 1, optimization, iBatch) || !cKey.Add(conv) ) return false; //--- Value conv = new CNeuronConvOCL(); if(!conv || !conv.Init(0, 14, OpenCL, iContWindow, iContWindow, iContHeads * iWindowKey, iContUnits, 1, optimization, iBatch) || !cValue.Add(conv) ) return false;
我们添加一个多头注意力结果层。
//--- Multi-Heads Attention Out neuron = new CNeuronBaseOCL(); if(!neuron || !neuron.Init(0, 15, OpenCL, iContHeads * iWindowKey * iContUnits, optimization, iBatch) || !cMHAttentionOut.Add(neuron) ) return false;
接下来是卷积伸缩层。
//--- Attention Out conv = new CNeuronConvOCL(); if(!conv || !conv.Init(0, 16, OpenCL, iContHeads * iWindowKey, iContHeads * iWindowKey, iContWindow, iContUnits, 1, optimization, iBatch) || !cAttentionOut.Add(conv) ) return false;
再者,最后一个是残差连接层。
//--- Residual neuron = new CNeuronBaseOCL(); if(!neuron || !neuron.Init(0, 17, OpenCL, conv.Neurons(), optimization, iBatch) || !cResidual.Add(neuron) ) return false;
现在我们需要添加 FeedForward 模块对象。与原版变换器类似,在该模块中,我们用到 2 个卷积层,它们之间有一个 LReLU 激活函数。
//--- Feed Forward conv = new CNeuronConvOCL(); if(!conv || !conv.Init(0, 18, OpenCL, iContWindow, iContWindow, 4 * iContWindow, iContUnits, 1, optimization, iBatch) || !cFeedForward.Add(conv) ) return false; conv.SetActivationFunction(LReLU); conv = new CNeuronConvOCL(); if(!conv || !conv.Init(0, 19, OpenCL, 4*iContWindow, 4*iContWindow, iContWindow, iContUnits, 1, optimization, iBatch) || !cFeedForward.Add(conv) ) return false;
在这种情况下,我们将用来自父类继承的类缓冲区作为残差连接层。不过,我们规划了替换误差梯度缓冲区的指针,从而减少数据复制操作。
if(!SetGradient(conv.getGradient())) return false; //--- SetOpenCL(OpenCL); //--- return true; }
在方法结束处,我们返回一个布尔值,示意调用程序的操作成功。
重点要注意,我们尚未创建存储注意力系数的数据缓冲区对象。这些缓冲区将在 OpenCL 关联环境中以独占方式实例化。它们的创建已移至单独的方法 CreateBuffers,我鼓励您在提供的附件中单独查看该方法。
对象初始化方法完成后,我们转至实现前向通验算法。它们在 feedForward 方法中定义。在此,值得注意的是与我们之前实现中所用的前馈通验方法的惯用结构略有偏差。虽然我们典型情况下传递指向神经层对象的指针作为主要输入,以及数据缓冲区指针作为次级,但在这种情况下,我们的神经层对象用到这两个输入。不过,在该阶段,这样实现仅适用于内部组件,在构建更高级别神经层对象的算法时所用。对于我们当前的目的,这是完全可以接受的。
bool CNeuronOCM::feedForward(CNeuronBaseOCL *Primitives, CNeuronBaseOCL *Context) { CNeuronBaseOCL *neuron = NULL, *q = cQuery[0], *k = cKey[0], *v = cValue[0];
在方法主体中,我们首先声明几个局部变量来临时存储指向神经层对象的指针。这些变量会被立即分配指向第一个注意力模块实体生成组件的指针。然后,我们验证指针的有效性,并依据自外部程序接收的基元张量生成必要的实体。
if(!q || !k || !v) return false; if(!q.FeedForward(Primitives) || !k.FeedForward(Primitives) || !v.FeedForward(Primitives) ) return false;
我们将得到的实体传递给多头注意力模块分析依赖性。
if(!AttentionOut(q, k, v, cScores[0], cMHAttentionOut[0], iPrimUnits, iPrimHeads, iPrimUnits, iPrimHeads, iWindowKey)) return false;
我们伸缩获得的结果,并将它们与相应的输入数据相加。之后,我们将结果归一化。
neuron = cAttentionOut[0]; if(!neuron || !neuron.FeedForward(cMHAttentionOut[0]) ) return false; v = cResidual[0]; if(!v || !SumAndNormilize(Primitives.getOutput(), neuron.getOutput(), v.getOutput(), iPrimWindow, true, 0, 0, 0, 1) ) return false; neuron = v;
作为第一个 自注意力 模块的输入,我们提供了有关所分析点云的丰富信息基元。在该模块中,我们进一步引入了内部依赖项。该步骤的目标是强调,对比下,与所分析场景最相关的那些基元。本质上,该阶段可与在点云上执行分段任务相比。不过,在我们的例子中,意图是定位按文本型表达式所述的目标对象。因此,我们进入下一阶段 — 交叉注意力。此处,我们将目标对象文本型描述的嵌入与所分析点云的关联基元对齐。为达成这一点,我们从对象数组中提取负责生成交叉注意力实体的神经层。我们验证获取的指针有效性。然后我们生成所需的实体。
//--- Cross-Attention q = cQuery[1]; k = cKey[1]; v = cValue[1]; if(!q || !k || !v) return false; if(!q.FeedForward(Context) || !k.FeedForward(neuron) || !v.FeedForward(neuron) ) return false;
我要提醒您,查询 实体是自目标对象描述嵌入生成的。“键” 和 “值” 实体是自上一个 自注意力 模块的输出生成的。接下来,我们将使用多头注意力机制。
if(!AttentionOut(q, k, v, cScores[1], cMHAttentionOut[1], iContUnits, iContHeads, iPrimUnits, iPrimHeads, iWindowKey)) return false;
然后,我们伸缩获得的结果,并配以残差连接补充。
neuron = cAttentionOut[1]; if(!neuron || !neuron.FeedForward(cMHAttentionOut[1]) ) return false; v = cResidual[1]; if(!v || !SumAndNormilize(Context.getOutput(), neuron.getOutput(), v.getOutput(), iContWindow, true, 0, 0, 0, 1) ) return false; neuron = v;
重点要注意,我们使用原始上下文张量作为残差连接。汇总两个张量的结果跨独立序列元素进行归一化。
在交叉注意力模块的输出处,我们期待获得目标对象描述的嵌入,其富含来自所分析点云的信息。换言之,我们的目标是 “高亮” 与所分析场景相关的目标对象描述的嵌入内容。
注意,在该阶段,我们不会针对所分析点云和目标对象描述进行直接比较。不过,在 RefMask3D 框架的以前阶段,我们经从原始点云中提取了基元。在交叉注意力模块中,我们从目标对象的描述中识别出在点云中找到的那些基元。然后,我们经由后续自注意力模块中的相互交互来丰富选定的嵌入,以便继续构造一个 “连贯的图片”。
如前,我们从内部动态数组中提取下一个实体生成层,并验证获得的指针。
//--- Context Self-Attention q = cQuery[2]; k = cKey[2]; v = cValue[2]; if(!q || !k || !v) return false;
之后,我们生成 查询、键 和 值 实体。在这种情况下,生成所有实体的输入数据是前一个交叉注意力模块的输出。
if(!q.FeedForward(neuron) || !k.FeedForward(neuron) || !v.FeedForward(neuron) ) return false;
我们还使用多头注意力算法来检测所分析数据序列中的相互依赖关系。
if(!AttentionOut(q, k, v, cScores[2], cMHAttentionOut[2], iContUnits, iContHeads, iPrimUnits, iPrimHeads, iWindowKey)) return false;
我们伸缩得到的结果,并添加融合后续数据归一化的残差连接。
q = cAttentionOut[1]; if(!q || !q.FeedForward(cMHAttentionOut[2]) ) return false; v = cResidual[2]; if(!v || !SumAndNormilize(q.getOutput(), neuron.getOutput(), v.getOutput(), iContWindow, true, 0, 0, 0, 1) ) return false; neuron = v;
然后我们需要通过 FeedForward 块传播丰富的上下文张量。我们把残差关系添加到得到的结果当中,并对数据进行归一化。我们将获得的数值写入 CNeuronOCM 类的结果缓冲区之中。该对象是从父类继承而来。
//--- Feed Forward q = cFeedForward[0]; k = cFeedForward[1]; if(!q || !k || !q.FeedForward(neuron) || !k.FeedForward(q) || !SumAndNormilize(neuron.getOutput(), k.getOutput(), Output, iContWindow, true, 0, 0, 0, 1) ) return false; //--- return true; }
在前馈通验方法结束处,我们只需将操作的布尔结果返回给调用程序。
一旦实现前馈通验方法完毕,我们继而组织反向传播通验的过程。如常,后向通验的功能分为两个阶段:根据误差梯度对模型整体性能的影响,将误差梯度分派到所有元素,以及优化可学习参数。相应地,我们将为每个阶段构造一个专用方法:calcInputGradients 和 updateInputWeights。第一种方法完全是前馈通验操作的逆过程。在第二个方法中,我们简单地在包含可训练参数的内部对象中按顺序调用同名的方法。我鼓励您独立探索这些方法的算法。该类及其所有方法的完整代码都包含在附件当中。
2. 构建 RefMask3D 框架
我们已经完成了实现 RefMask3D 框架各个模块的大量工作,现在是时候将所有内容汇编成一个统一的对象,将单独的模块集成到结构良好的架构当中。为了执行该任务,我们将创建一个新类 CNeuronRefMask,其结构如下所示。
class CNeuronRefMask : public CNeuronBaseOCL { protected: CNeuronGEGWA cGEGWA; CLayer cContentEncoder; CLayer cBackGround; CNeuronLPC cLPC; CLayer cDecoder; CNeuronOCM cOCM; //--- virtual bool feedForward(CNeuronBaseOCL *NeuronOCL) override { return false; } virtual bool feedForward(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput) override; //--- virtual bool calcInputGradients(CNeuronBaseOCL *NeuronOCL) override { return false; } virtual bool calcInputGradients(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput, CBufferFloat *SecondGradient, ENUM_ACTIVATION SecondActivation = None) override; virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL) override { return false; } virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput) override; public: CNeuronRefMask(void) {}; ~CNeuronRefMask(void) {}; //--- virtual bool Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint window_key, uint units_count, uint heads, uint content_size, uint content_units, uint primitive_units, uint layers, ENUM_OPTIMIZATION optimization_type, uint batch); //--- virtual int Type(void) override const { return defNeuronRefMask; } //--- 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 方法中执行。
如您所知,该方法的参数提供了常量,令我们能够清楚地识别正创建对象的架构。不过,内部对象的复杂性和数量会导致架构的高度可变性。这反过来又增加了所需描述性参数的数量。在我们看来,过多的参数只会让类的使用复杂化。因此,我们选择统一的内部对象参数,从而显著减少外部输入的数量。这意味着初始化方法应当仅保留定义输入和输出数据参数的常量。若可能的话,内部对象将重用外部数据参数。举例,仅为输入数据指定单个序列元素的窗口大小。但相同的参数也用于生成可学习基元的嵌入,并作为上下文的嵌入大小。如此这般,定义基元和上下文的序列长度对于张量构造来说就足够了。
bool CNeuronRefMask::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint window_key, uint units_count, uint heads, uint content_size, uint content_units, uint primitive_units, uint layers, ENUM_OPTIMIZATION optimization_type, uint batch) { if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, window * content_units, optimization_type, batch)) return false;
如常,该方法内的第一个操作是调用父类的同名方法,其中已包含验证接收参数和继承对象初始化的最小逻辑。此后,我们继续初始化声明的对象。我们起步从使用之前实现的模块初始化点云编码器:几何-强化群-单词注意力。
//--- Geometry-Enhaced Group-Word Attention if(!cGEGWA.Init(0, 0, OpenCL, window, window_key, heads, units_count, window, heads, (content_units + 3), 2, layers, optimization, iBatch)) return false; cGEGWA.AddNeckGradient(true);
请注意以下两个时刻。首先,当指定上下文的序列长度时,我们把目标对象描述的嵌入大小多加 3 个元素。如之前实现,我们不会提供目标对象的文本型描述。取而代之,我们将据描述当前账户状态和持仓的向量生成一系列令牌。基本原理是相同的:从单个账户状态描述生成多个不同的令牌,可更全面的分析当前市场状况。不过,我们承认输入数据可能包含噪声和异常值。为了减弱它们的影响,我们引入了三个额外的可学习令牌,来积累不相关的值。本质上,这是一种 “背景” 令牌,正如 RefMask3D 框架的作者所提议。
我们的点编码器在每个阶段都用到两层注意力模块。从外部程序接收的 layers 参数指定了 U-形模块的 “瓶颈” 中的嵌入数量。
此外,我们还为瓶颈模块启用了梯度求和功能。
随后,我们继续进行上下文编码器。Ф 不为该模型创建单独的模块。不过,您已经熟悉它的架构。它完全复现了优化 3D-GRES 方法中优调表达式的编码器。该过程首先创建一个全连接层,该层存储表示当前账户状态的向量。
//--- Content Encoder cContentEncoder.Clear(); cContentEncoder.SetOpenCL(OpenCL); CNeuronBaseOCL *neuron = new CNeuronBaseOCL(); if(!neuron || !neuron.Init(window * content_units, 1, OpenCL, content_size, optimization, iBatch) || !cContentEncoder.Add(neuron) ) return false;
然后我们添加一个全连接层来生成给定数量的所需大小嵌入。
neuron = new CNeuronBaseOCL(); if(!neuron || !neuron.Init(0, 2, OpenCL, window * content_units, optimization, iBatch) || !cContentEncoder.Add(neuron) ) return false;
在此,我们加了另一层,我们将在其中写入上下文和 “background” 令牌的级联张量。
neuron = new CNeuronBaseOCL(); if(!neuron || !neuron.Init(0, 3, OpenCL, window * (content_units + 3), optimization, iBatch) || !cContentEncoder.Add(neuron) ) return false;
下一步是创建一个模型,生成可学习背景令牌的张量。此处我们还用到两层 MLP。它的第一层是静态的,包含 “1”。第二层根据可学习参数生成所需大小的张量。
//--- Background cBackGround.Clear(); cBackGround.SetOpenCL(OpenCL); neuron = new CNeuronBaseOCL(); if(!neuron || !neuron.Init(window * 3, 4, OpenCL, content_size, optimization, iBatch) || !cBackGround.Add(neuron) ) return false; neuron = new CNeuronBaseOCL(); if(!neuron || !neuron.Init(0, 5, OpenCL, window * 3, optimization, iBatch) || !cBackGround.Add(neuron) ) return false;
然后我们添加语言原语模块。
//--- Linguistic Primitive Construction if(!cLPC.Init(0, 6, OpenCL, window, window_key, heads, heads, primitive_units, content_units, 2, 1, optimization, iBatch)) return false;
接下来是解码器。此处,我们与原始方法的作者所提议架构略有偏差:我们用之前开发的对象聚集模块替换了原版变换器解码器层。我们之前已讨论过这些模块之间的相似之处和不同之处。我们希望这种方式将进一步提高结果模型的效率。
还值得注意的是,根据 RefMask3D 框架作者提议的结构,每个解码器层都配合对应的 U-形点编码器层进行依赖关系分析。为了实现该方式,我们规划了一个循环,按顺序提取相应的对象。
//--- Decoder cDecoder.Clear(); cDecoder.SetOpenCL(OpenCL); CNeuronOCM *ocm = new CNeuronOCM(); if(!ocm || !ocm.Init(0, 7, OpenCL, window, window_key, units_count, heads, window, primitive_units, heads, optimization, iBatch) || !cDecoder.Add(ocm) ) return false; for(uint i = 0; i < layers; i++) { neuron = cGEGWA.GetInsideLayer(i); ocm = new CNeuronOCM(); if(!ocm || !neuron || !ocm.Init(0, i + 8, OpenCL, window, window_key, neuron.Neurons() / window, heads, window, primitive_units, heads, optimization, iBatch) || !cDecoder.Add(ocm) ) return false; }
我们现在只需初始化对象聚集模块。
//--- Object Cluster Module if(!cOCM.Init(0, layers + 8, OpenCL, window, window_key, primitive_units, heads, window, content_units, heads, optimization, iBatch)) return false;
然后我们替换指向数据缓冲区的指针,这令我们能够减少复制操作的数量。
if(!SetOutput(cOCM.getOutput()) || !SetGradient(cOCM.getGradient()) ) return false; //--- return true; }
在方法结束处,我们返回一个布尔值,示意调用程序的操作成功。这样就完成了类对象初始化方法的构造,我们可以继续组织我们在 feedForward 方法中实现的前馈传递算法。在此方法的参数中,我们接收指向原始数据的两个对象的指针。第一个表示为指向神经层对象的指针,第二个是数据缓冲区。这是我们在基本模型中组织接口的方案。
bool CNeuronRefMask::feedForward(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput) { if(!SecondInput) return false;
在方法的主体中,我们检查接收到的指针与初始数据的第二个源的相关性,并在必要时将指针替换为上下文编码器第一层中的结果缓冲区。
//--- Context Encoder CNeuronBaseOCL *context = cContentEncoder[0]; if(context.getOutput() != SecondInput) { if(!context.SetOutput(SecondInput, true)) return false; }
之后,我们根据提供的数据生成上下文嵌入。
int content_total = cContentEncoder.Total(); for(int i = 1; i < content_total - 1; i++) { context = cContentEncoder[i]; if(!context || !context.FeedForward(cContentEncoder[i - 1]) ) return false; }
注意,前馈通验操作自上下文嵌入生成开始。点编码器使用该信息作为初始数据的第二个来源。
接下来,我们生成一个背景令牌张量。
//--- Background Encoder CNeuronBaseOCL *background = NULL; if(bTrain) { for(int i = 1; i < cBackGround.Total(); i++) { background = cBackGround[i]; if(!background || !background.FeedForward(cBackGround[i - 1]) ) return false; } } else { background = cBackGround[cBackGround.Total() - 1]; if(!background) return false; }
我们将其与上下文嵌入张量级联起来。
CNeuronBaseOCL *neuron = cContentEncoder[content_total - 1]; if(!neuron || !Concat(context.getOutput(), background.getOutput(), neuron.getOutput(), context.Neurons(), background.Neurons(), 1)) return false;
接下来,我们将级联的张量、与从外部程序接收的第一个初始数据源的指针一起,传输到我们的点编码器。
//--- Geometry-Enhaced Group-Word Attention if(!cGEGWA.FeedForward(NeuronOCL, neuron.getOutput())) return false;
此外,我们将上下文嵌入传递给语言原语生成模块。仅在这种情况下,我们会用到没有背景令牌的张量。
//--- Linguistic Primitive Construction if(!cLPC.FeedForward(context)) return false;
应当注意的是,背景令牌仅在点编码器中使用,以便过滤掉噪声和异常值。
在该阶段,我们已形成了语言原语和原始点云的嵌入张量。下一步是在我们的解码器中匹配它们,这将有助于识别所分析场景中固有的语言原语。此处,我们首先将点编码器的结果映射到我们的原语。
//--- Decoder CNeuronOCM *decoder = cDecoder[0]; if(!decoder.feedForward(GetPointer(cGEGWA), GetPointer(cLPC))) return false;
然后,我们用点编码器的中间结果来丰富语言原语的嵌入。为此,我们创建了一个循环,在其中我们将按顺序提取解码器的后续层、和点编码器的相应对象,并进行后续数据比较。
for(int i = 1; i < cDecoder.Total(); i++) { decoder = cDecoder[i]; if(!decoder.feedForward(cGEGWA.GetInsideLayer(i - 1), cDecoder[i - 1])) return false; }
我们遍历对象聚集模型通验解码器结果。
//--- Object Cluster Module if(!cOCM.feedForward(decoder, context)) return false; //--- return true; }
此后,我们把操作的逻辑结果返回给调用程序,完成前馈方法的执行。
值得一提的是,实现的算法并不是原始 RefMask3D 框架的精确复刻。在原始算法中,点编码器的输出再乘以对象聚集模块的输出,连同一个头,即判定给指定对象分配点的概率。算法 “修剪” 的原因在于,所解决的任务有所不同。我们不需要对所分析场景中的单个对象进行视觉分段。为了制定有关交易操作的决定,了解所需形态的存在、及其参数就足够了。因此,我们决定以这种形式实现拟议的框架。其操作结果将由参与者模型进行分析。
我们迈步前进。在实现前馈算法之后,我们继续开发反向传播过程的方法。在此, 有几句话要说说误差梯度分派方法 calcInputGradients。如常,该方法完全逆转前馈操作。不过,重点要注意,在前馈通验期间,我们会生成一定范围的实体,在模型的有效性中扮演至关重要的角色。这些包括可训练原语、上下文嵌入、和背景令牌。自然,我们打算生成的实体集合尽可能多元化,旨在覆盖所观察市场景象的最广阔空间。尽管我们已在语言原语生成模型中实现了该功能,但仍需要针对其它实体进行开发。因此,我建议花几分钟时间回顾一下构建误差梯度分派方法的算法。
bool CNeuronRefMask::calcInputGradients(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput, CBufferFloat *SecondGradient, ENUM_ACTIVATION SecondActivation = None) { if(!NeuronOCL || !SecondGradient) return false;
该方法接收三个参数:指向一个神经层对象、和两个数据缓冲区的指针。如您所知,神经层对象包含的缓冲区,对应第一个源的结果、和误差梯度。不过对于第二个数据源,我们会收到单独缓冲区,对应输入数据、和相应误差梯度。此外,还提供了指向第二个数据源的激活函数的指针。
在方法主体中,我们立即验证第一个数据源指针的有效性,和第二个数据源的误差梯度。缺了指向第二个源的输入数据缓冲区的有效指针并不重要,因为在前馈通验期间保留了已验证的指针。
如有必要,我们将内部对象中的误差梯度缓冲区替换为第二个数据源。
CNeuronBaseOCL *neuron = cContentEncoder[0]; if(!neuron) return false; if(neuron.getGradient() != SecondGradient) { if(!neuron.SetGradient(SecondGradient)) return false; neuron.SetActivationFunction(SecondActivation); }
如此准备工作就完成了,我们继续进行实际的误差梯度分派操作。
由于在对象初始化期间执行了指针替换,从后续层接收到的误差梯度被直接写入对象聚集模块的缓冲区。因此,避免了不必要的数据复制,我们经由 OCM 对象分派误差梯度开始操作。
//--- Object Cluster Module CNeuronBaseOCL *context = cContentEncoder[cContentEncoder.Total() - 2]; if(!cOCM.calcInputGradients(cDecoder[cDecoder.Total() - 1], context)) return false;
注意,在这种情况下,我们将梯度传递给最后一个解码器层,及倒数第二个上下文编码器层。原因是最后一个上下文编码器层包含上下文嵌入、和背景令牌的级联张量,其仅由点编码器所用。
接下来,我们经由解码器传播误差梯度。为了达成这一点,我们规划了一个反向循环迭代解码器层。
//--- Decoder CNeuronOCM *decoder = NULL; for(int i = cDecoder.Total() - 1; i > 0; i--) { decoder = cDecoder[i]; if(!decoder.calcInputGradients(cGEGWA.GetInsideLayer(i - 1), cDecoder[i - 1])) return false; } decoder = cDecoder[0]; if(!decoder.calcInputGradients(GetPointer(cGEGWA), GetPointer(cLPC))) return false;
请注意,在误差梯度分派过程中,我们将梯度传播到点编码器的内层。正是为了保留这些值,我们之前为瓶颈对象实现了误差梯度汇总算法。
解码器的第二个数据源是 LPC 基元生成模块。此处得到的误差梯度会分派到内部的原语生成模块、及没有背景令牌的上下文嵌入。不过,后一个缓冲区已包含来自先前操作的数据。因此,我们暂时将上下文嵌入梯度缓冲区指针替换为继承自父类的未用缓冲区。仅在此之后,我们才会调用 LPC 模块的误差梯度分派方法。然后,我们将两个数据缓冲区的值汇总。
//--- Linguistic Primitive Construction CBufferFloat *context_grad = context.getGradient(); if(!context.SetGradient(PrevOutput, false)) return false; if(!cLPC.FeedForward(context) || !SumAndNormilize(context_grad, context.getGradient(), context_grad, 1, false, 0, 0, 0, 1) ) return false;
接下来,我们经由点编码器传播误差梯度。这次,我们在原始数据的第一个源、及带有背景令牌的上下文嵌入之间分派误差梯度。
//--- Geometry-Enhaced Group-Word Attention neuron = cContentEncoder[cContentEncoder.Total() - 1]; if(!neuron || !NeuronOCL.calcHiddenGradients((CObject*)GetPointer(cGEGWA), neuron.getOutput(), neuron.getGradient(), (ENUM_ACTIVATION)neuron.Activation())) return false;
重点要注意,我们必须结合上下文和背景令牌两者的多元化。如您所见,背景令牌和上下文属于同一个子空间。甚至,除了使上下文和背景令牌多元化之外,我们还必须在这些实体之间建立清晰的区别。因此,我们首先在上下文和背景的级联张量中增加一个多元化误差。
if(!DiversityLoss(neuron, cOCM.GetContextWindow(), neuron.Neurons() / cOCM.GetContextWindow(), true)) return false; CNeuronBaseOCL *background = cBackGround[cBackGround.Total() - 1]; if(!background || !DeConcat(context.getGradient(), background.getGradient(), neuron.getGradient(), context.Neurons(), background.Neurons(), 1) || !DeActivation(context.getOutput(), context.getGradient(), context.getGradient(), context.Activation()) || !SumAndNormilize(context_grad, context.getGradient(), context_grad, 1, false, 0, 0, 0, 1) || !context.SetGradient(context_grad, false) ) return false;
接下来,我们将结果误差梯度分派到这些实体的相应缓冲区之中。我们通过应用激活函数的导数来调整上下文梯度,并将结果值添加到先前累积的数值当中。之后,我们将指针恢复到相应的数据缓冲区。自此刻,我们可将误差梯度向下传播到第二个数据源。
//--- Context Encoder for(int i = cContentEncoder.Total() - 3; i >= 0; i--) { context = cContentEncoder[i]; if(!context || !context.calcHiddenGradients(cContentEncoder[i + 1]) ) return false; }
回想一下,指向误差梯度缓冲区的指针已经保存在相应的内部神经层对象之中。如是结果,在缓冲区之间显式复制数据变得多余。
在该阶段,我们已将误差梯度传播到数据源和几乎所有内部组件。“几乎” — 因为最后一步是通过背景令牌生成模型分派误差梯度。我们使用激活函数的导数调整先前获得的梯度,并在涵盖 MLP 层启动反向迭代循环。
//--- Background if(!DeActivation(background.getOutput(), background.getGradient(), background.getGradient(), background.Activation())) return false; for(int i = cBackGround.Total() - 2; i > 0; i--) { background = cBackGround[i]; if(!background || !background.calcHiddenGradients(cBackGround[i + 1]) ) return false; } //--- return true; }
最后,在梯度分派方法的末尾,我们将逻辑结果返回给调用程序,表明操作成功。
据此,我们针对 RefMask3D 框架实现算法的讨论完毕。您可在附件中找到所有提供的类、及其方法的完整源代码。同一附件还包括经过训练的模型的架构,以及在准备本文期间用到的所有程序。
仅对模型架构进行了细微的调整,特别是修改了编码器中负责描述环境状态的单个层。互动和训练程序是自以前的工作中继承而来,没有任何变化。因此,我们不会在这里重审它们,取而代之是继续本文的最后一部分 — 训练模型,并评估它们的性能。
3. 测试
如前所述,对模型架构的修改不会影响输入数据的结构、或输出结果。这意味着我们可以重用之前收集的训练数据集进行初始模型训练。回想一下,我们在 H1 时间帧上取 EURUSD 金融产品 2023 年全年的真实历史数据。所有指标参数均按其默认值设置。
模型训练是离线执行的。不过,为了保持训练数据集的相关性,我们会定期更新它,基于当前参与者政策添加新局次。重复模型训练和数据集更新,直至达成所需的性能。
在准备本文期间,我们开发了一个相当有趣的参与者政策。其据 2024 年 1 月历史数据的测试结果如下所示。
测试区期未包括在训练数据集当中。这种测试方法尽可能地模拟在真实世界里模型的用法。
在测试期间,该模型执行了 21 笔交易,其中 14 笔是盈利的,占比超过 66%。值得注意的是,在做空和做多仓位中,盈利交易的比例都超过了亏损交易的比例。甚至,每笔盈利交易的平均利润是每笔亏损交易平均亏损的两倍。最大盈利交易几乎是最大亏损的三倍。余额图表展示了清晰定义的上升趋势。
当然,交易数量有限,我们无法对模型的长期有效性得出确切的结论。然而,所提议方式清晰地展现出前景,值得进一步探索。
结束语
在过去的两篇文章中,我们利用 MQL5 完成了 RefMask3D 框架中提议的方法。当然,我们的实现与原始框架略有不同。无论如何,获得的结果证明了这种方式的潜力。
不过,我必须强调,本文中讲述的所有程序仅用于演示目的,尚不适用于现实世界的交易条件。
参考
文章中所用程序
# | 名称 | 类型 | 说明 |
---|---|---|---|
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/16057
注意: MetaQuotes Ltd.将保留所有关于这些材料的权利。全部或部分复制或者转载这些材料将被禁止。
本文由网站的一位用户撰写,反映了他们的个人观点。MetaQuotes Ltd 不对所提供信息的准确性负责,也不对因使用所述解决方案、策略或建议而产生的任何后果负责。




德米特里 你好。我在培训过程中遇到了这个错误:
这是什么意思?
顺便说一下,编译时出现了这两个警告:
文章中的文件保持不变。
这篇文章非常好,我打算下载下来,周末试着用一下。回溯测试报告 中没有显示两样东西,一是使用的货币对,二是时间框架。 您能否提供这些信息,或者参考一下以前的文章? 我刚刚找到了答案,是欧元兑美元和 H1
Viktor,我遇到过同样的备忘错误(Deprecated behavior)。在我的案例中,我在开发一个类时,无意中调用了一个缺少参数的可见函数,但该类包含正确的参数。 添加参数后,问题就解决了。probram 使用备忘错误正确运行。