交易中的神经网络:场景感知物体检测(HyperDet3D)
概述
近年来,物体检测引起了广泛关注。PointNet++ 基于特征学习和体积卷积,强调局部几何,优雅地分析原生点云。这导致它在各种物体检测模型中作为骨干网络而被广泛采用。
不过,类似物体的属性可能模棱两可,这会拉低模型性能。如是结果,模型的适用性变得有限,或者其架构必须变得更加复杂。论文《HyperDet3D:学习条件化场景的 3D 物体检测器》的作者观察到,场景级信息提供了有助于解决物体属性解释歧义的先验知识。反过来,从场景理解的角度来看,这可以防止不合逻辑的检测结果。
该论文阐述了检测点云中 3D 物体的 HyperDet3D 算法,其使用基于超网络的架构。HyperDet3D 学习条件化场景信息,并将场景级别知识整合到网络参数之中。这令 3D 物体检测器能够动态适应不同的输入数据。具体上,条件化场景知识可以分解为两个级别:不变性场景信息,和特定性场景信息。
为了捕获不变性场景知识,作者提议运用超网络学习嵌入,并随着模型在不同场景中的训练而迭代更新。这种不变性场景信息通常是从训练数据的特征中抽象出来的,检测器可在推理过程中利用这些信息。
甚至,由于传统检测器在检测不同场景中的物体时保持一组固定的参数,因此 HyperDet3D 的作者提议集成特定性场景信息,以便令检测器在推理时适配特定场景。这是通过分析当前场景与正常表示的对齐度、或偏差度来达成的,使用特定输入作为查询。
改论文阐述了一种新颖的模块结构,称为多头条件化场景注意了(MSA)。MSA 启用先验知识与候选物体特征的聚合,由此促进更有效的物体检测。
1. HyperDet3D 算法
HyperDet3D 模型包括三个主要组件:
- 一个主干编码器
- 物体解码器层
- 一个检测头
输入的点云首先经主干处理,其对点进行降解采样,以生成初始物体候选者,并利用层次化架构粗略提取其特征。作者提议采用 PointNet++ 作为主干网络。
接下来,物体解码器层细化候选者特征,将条件化场景的先验知识集成到物体级表示当中。然后,检测头基于这些候选者对象的位置和优化特征回归边界框。
为了启动 HyperDet3D 去了解场景级别元信息,作者引入了一个 HyperNetwork,这是一个用于参数化主网络可训练参数的神经网络。与在推理过程中维持固定参数的标准深度神经网络不同,超网络提供了灵活性,可基于输入数据适配参数。
HyperDet3D 应用条件化场景超网络,将先验知识整合到变换器解码器层的参数当中。这允许检测网络动态适配不同的输入场景。关键思路是,利用由 𝑾 参数化、及条件化场景超网络提供的先验知识,丰富由主干编码器生成的候选集形成的物体表示 𝒐。
条件化场景超网络生成的参数分为不变性场景分量,和特定场景分量。
为了获得不变性场景知识,作者提议训练一组 n 个与场景无关的嵌入向量 𝒁a,,然后供超网络消费。该超网络的输出是一个权重矩阵 𝑾a,它把不变性场景的先验知识参数化。
鉴于物体属性是经一连串解码器层迭代优化的,故它们能逐渐与不变性场景超网络的输出融合。该网络抽象出不同 3D 场景中的先验知识。如是结果,HyperDet3D 不仅在所有解码器级别上维持普适的条件化场景知识,而且还分享经由丰富的特征层次化得来的知识,从而节省计算资源。
为了获得特定场景的知识,该模型学习了一组拟似 𝒁a 的嵌入 𝒁s。但在这种情况下,𝒁s 应当捕获单个场景独有的信息。这是经由交叉注意力模块达成的,其中当前场景的嵌入会与所学习特定场景的嵌入𝒁s 比较。通过注意力机制,该模型评估 𝒁s 与嵌入空间中当前场景的一致性程度(或偏差程度)。
下面提供了 HyperDet3D 方法的官方可视化。

2. 利用 MQL5 实现
在研究了 HyperDet3D 方法的理论层面之后,我们转入本文的实践部分,其中我们实现了对所提议方式的愿景。
有言在先:我们还有大量的工作待定。为了有效地管理这一点,我们将实现划分为若干个逻辑模块。那么,我们卷起袖子开始吧。
2.1特定场景知识模块
我们首先构建负责学习特定场景知识的模块。正如理论章节所讨论的,交叉注意力用来匹配当前场景与特定场景知识嵌入。相应地,我们将定义一个新类 CNeuronSceneSpecific,作为交叉注意力模块 CNeuronMLCrossAttentionMLKV 的子类。新类结构如下所示。
class CNeuronSceneSpecific : public CNeuronMLCrossAttentionMLKV { protected: CNeuronBaseOCL cOne; CNeuronBaseOCL cSceneSpecificKnowledge; //--- virtual bool feedForward(CNeuronBaseOCL *NeuronOCL) override; virtual bool feedForward(CNeuronBaseOCL *NeuronOCL, CBufferFloat *Context) override { return feedForward(NeuronOCL); } //--- virtual bool calcInputGradients(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput, CBufferFloat *SecondGradient, ENUM_ACTIVATION SecondActivation = None) override { return calcInputGradients(NeuronOCL); } virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL, CBufferFloat *Context) override { return updateInputWeights(NeuronOCL); } virtual bool calcInputGradients(CNeuronBaseOCL *NeuronOCL) override; virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL) override; public: CNeuronSceneSpecific(void) {}; ~CNeuronSceneSpecific(void) {}; //--- virtual bool Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint window_key, uint heads, uint heads_kv, uint units_count, uint units_count_kv, uint layers, uint layers_to_one_kv, ENUM_OPTIMIZATION optimization_type, uint batch); //--- virtual int Type(void) override const { return defNeuronSceneSpecific; } //--- 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; };
在此,重点是强调我们的新类与其父类之间的一个根本区别。父类需要两个数据源才能功能正常:输入数据,和上下文。然而,在我们的新类中,上下文是由来自训练集中得出的特定场景的学习数据表示。该数据经由两个内层进行学习:cOne 和 cSceneSpecificKnowledge。本质上,这形成了一个两层 MLP,它接受标量输入(1),并生成一个代表特定场景知识的张量。如您所料,在推理过程中,该张量维持静态。不过,在训练期间,模型会将必要的信息“写入”其中。
按照这个逻辑,我们从新类的方法中排除指向外部上下文的指针。
所有对象都声明为静态,允许我们将类构造函数和析构函数保留为 “空”。对象初始化在 Init 方法中执行。在其参数中,我们获得对象架构的主要常量。所用参数的功能类似于父类的相关方法。
bool CNeuronSceneSpecific::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint window_key, uint heads, uint heads_kv, uint units_count, uint units_count_kv, uint layers, uint layers_to_one_kv, ENUM_OPTIMIZATION optimization_type, uint batch) { if(!CNeuronMLCrossAttentionMLKV::Init(numOutputs, myIndex, open_cl, window, window_key, heads, 16, heads_kv, units_count, units_count_kv, layers, layers_to_one_kv, optimization_type, batch)) return false;
在方法主体中,我们首先调用父类的初始化方法,并传递收到的所有参数。该父方法处理参数验证和继承组件的设置。
接下来,我们初始化前面提到的特定场景的 MLP。
注意,第一层仅包含一个常量输入。第二层生成一组嵌入向量,表示特定场景的知识。每个嵌入向量的长度为 16 个元素。嵌入的数量由方法参数定义,并取决于建模环境的复杂程度。
if(!cOne.Init(16 * units_count_kv, 0, OpenCL, 1, optimization, iBatch)) return false; CBufferFloat *out = cOne.getOutput(); if(!out.BufferInit(1, 1) || !out.BufferWrite()) return false; if(!cSceneSpecificKnowledge.Init(0, 1, OpenCL, 16 * units_count_kv, optimization, iBatch)) return false; //--- return true; }
在该方法完结之前,我们返回一个布尔值,指示调用初始化函数是成功或失败。
我们新类的初始化方法相对短小精悍。理由很充分:核心功能已在父类中实现。该范式不仅适用于初始化方法。这也适用于 feedForward 方法,其参数包括指向源输入数据的指针。
bool CNeuronSceneSpecific::feedForward(CNeuronBaseOCL *NeuronOCL) { if(bTrain && !cSceneSpecificKnowledge.FeedForward(cOne.AsObject())) return false;
在该方法主体中,我们首先需要生成一个所学习场景上下文相关表示的矩阵。但我们仅在模型训练过程中执行该操作,此时生成的张量会随着我们调整 MLP 的参数而发生变化。在模型操作期间,这些所学习数值是静态的,不需要重新计算。我们简单地复用预先保存的信息。
最后,我们调用父类的 feedForward 方法,传递特定场景的知识张量作为上下文输入。
if(!CNeuronMLCrossAttentionMLKV::feedForward(NeuronOCL, cSceneSpecificKnowledge.getOutput())) return false; //--- return true; }
构造反向传播传递方法按类似方式。为简洁起见,也为了防止文章篇幅变得冗长,我建议独立研究它们。该类及其所有方法的完整实现,可在随附的素材中找到。
2.2构建 MSA 模块
现在,我们转到构造多头条件化场景注意力(MSA)模块。自然而然,我们将从先前实现的注意力模块之一继承核心功能。新类 CNeuronMLMHSceneConditionAttention 的结构如下所示。
class CNeuronMLMHSceneConditionAttention : public CNeuronMLMHAttentionMLKV { protected: CLayer cSceneAgnostic; CLayer cSceneSpecific; //--- virtual bool feedForward(CNeuronBaseOCL *NeuronOCL) override; //--- virtual bool calcInputGradients(CNeuronBaseOCL *prevLayer) override; virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL) override; public: CNeuronMLMHSceneConditionAttention(void) {}; ~CNeuronMLMHSceneConditionAttention(void) {}; //--- virtual bool Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint window_key, uint heads, uint heads_kv, uint units_count, uint layers, uint layers_to_one_kv, ENUM_OPTIMIZATION optimization_type, uint batch) override; //--- virtual int Type(void) override const { return defNeuronMLMHSceneConditionAttention; } //--- 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; };
在该结构内,您将注意到两个 CLayer 类型的新对象声明。一个存储场景的上下文相关表示,而另一个将保存独立于场景的普通对象相关信息。
重点要注意,这两个对象的存在,并未约束创建更深的嵌套神经层,以便识别物体。在这种情况下,CLayer 对象用作动态数组。而内部神经层的数量由用户在对象初始化期间定义。
所有内部对象都声明为静态,允许我们将构造函数和析构函数留空。如常,所有内部和继承对象的初始化都在 Init 方法中执行。
bool CNeuronMLMHSceneConditionAttention::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint window_key, uint heads, uint heads_kv, uint units_count, uint layers, uint layers_to_one_kv, ENUM_OPTIMIZATION optimization_type, uint batch) { if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, window * units_count, optimization_type, batch)) return false;
在方法参数中,我们接收确定所创建对象架构的主要常量。在方法主体中,我们立即调用相关的祖先方法。不过,在这种情况下,我们使用的方法不是直接父类中的方法,而是基本全连接层 CNeuronBaseOCL 里的方法。这是由于继承对象的结构和大小存在显著差异。
在祖先初始化方法的操作成功完成后,我们将保存新类的架构常量。
iWindow = fmax(window, 1); iWindowKey = fmax(window_key, 1); iUnits = fmax(units_count, 1); iHeads = fmax(heads, 1); iLayers = fmax(layers, 1); iHeadsKV = fmax(heads_kv, 1); iLayersToOneKV = fmax(layers_to_one_kv, 1);
然后我们计算内部对象的尺寸。
uint num_q = iWindowKey * iHeads * iUnits; //Size of Q tensor uint num_kv = 2 * iWindowKey * iHeadsKV * iUnits; //Size of KV tensor uint q_weights = (iWindow * iHeads) * iWindowKey; //Size of weights' matrix of Q tenzor uint kv_weights = 2 * (iWindow * iHeadsKV) * iWindowKey; //Size of weights' matrix of KV tenzor uint scores = iUnits * iUnits * iHeads; //Size of Score tensor uint mh_out = iWindowKey * iHeads * iUnits; //Size of multi-heads self-attention uint out = iWindow * iUnits; //Size of out tensore uint w0 = (iWindowKey * iHeads + 1) * iWindow; //Size W0 tensor uint ff_1 = 4 * (iWindow + 1) * iWindow; //Size of weights' matrix 1-st feed forward layer uint ff_2 = (4 * iWindow + 1) * iWindow; //Size of weights' matrix 2-nd feed forward layer
我们再添加 2 个局部变量,临时存储指向神经层对象的指针。
CNeuronBaseOCL *base = NULL; CNeuronSceneSpecific *ss = NULL;
准备工作到此完结。接下来,我们组织一个循环,迭代次数等于用户在方法参数中指定的内层数量。在该循环的每次迭代中,我们创建一个内层的对象。相应地,在指定数量的循环彻底完成迭代后,我们按内部神经层正常运行所需的数量创建完整对象集。
for(uint i = 0; i < iLayers; i++) { CBufferFloat *temp = NULL; for(int d = 0; d < 2; d++) { //--- Initilize Q tensor temp = new CBufferFloat(); if(CheckPointer(temp) == POINTER_INVALID) return false; if(!temp.BufferInit(num_q, 0)) return false; if(!temp.BufferCreate(OpenCL)) return false; if(!QKV_Tensors.Add(temp)) return false;
在循环主体中,我们立即创建另一个包含 2 次迭代的嵌套循环。在嵌套循环主体中,我们把前向通验的主流数据、以及反向传播通验的相应误差梯度记录保存到数据缓冲区。这个 2-次迭代循环允许我们为前馈和反向传播通验创建一个镜像架构。
此处,我们首先创建一个缓冲区来记录生成的 查询 实体。然后,我们创建一个缓冲区来记录该生成实体的权重矩阵。
//--- Initilize Q weights temp = new CBufferFloat(); if(CheckPointer(temp) == POINTER_INVALID) return false; if(!temp.BufferInit(q_weights, 0)) return false; if(!temp.BufferCreate(OpenCL)) return false; if(!QKV_Weights.Add(temp)) return false;
注意,以前我们总是用在模型训练过程期间调整的随机值填充权重矩阵。这一次,我们创建了一个值为零的缓冲区。这是由于超网络架构的实现,它会参考所分析场景来生成该矩阵。
我们重复类似的操作来生成 键 和 值 实体数据缓冲区。但在此,我们还增加了若干内层使用一个张量的能力。因此,在创建数据缓冲区之前,我们检查如此操作的可行性。
if(i % iLayersToOneKV == 0) { //--- Initilize KV tensor temp = new CBufferFloat(); if(CheckPointer(temp) == POINTER_INVALID) return false; if(!temp.BufferInit(num_kv, 0)) return false; if(!temp.BufferCreate(OpenCL)) return false; if(!KV_Tensors.Add(temp)) return false; //--- Initilize KV weights temp = new CBufferFloat(); if(CheckPointer(temp) == POINTER_INVALID) return false; if(!temp.BufferInit(kv_weights, 0)) return false; if(!temp.BufferCreate(OpenCL)) return false; if(!KV_Weights.Add(temp)) return false; }
接下来,我们添加一个缓冲区来存储依赖系数。
//--- Initialize scores temp = new CBufferFloat(); if(CheckPointer(temp) == POINTER_INVALID) return false; if(!temp.BufferInit(scores, 0)) return false; if(!temp.BufferCreate(OpenCL)) return false; if(!S_Tensors.Add(temp)) return false;
另一个缓冲区存储多头注意力的结果。
//--- Initialize multi-heads attention out temp = new CBufferFloat(); if(CheckPointer(temp) == POINTER_INVALID) return false; if(!temp.BufferInit(mh_out, 0)) return false; if(!temp.BufferCreate(OpenCL)) return false; if(!AO_Tensors.Add(temp)) return false;
如前,多头注意力的结果将缩放到原始数据的大小。我们将该操作的结果写入相应的数据缓冲区。
//--- Initialize attention out temp = new CBufferFloat(); if(CheckPointer(temp) == POINTER_INVALID) return false; if(!temp.BufferInit(out, 0)) return false; if(!temp.BufferCreate(OpenCL)) return false; if(!FF_Tensors.Add(temp)) return false;
我们还添加了 FeedForward 模块操作缓冲区。
//--- Initialize Feed Forward 1 temp = new CBufferFloat(); if(CheckPointer(temp) == POINTER_INVALID) return false; if(!temp.BufferInit(4 * out, 0)) return false; if(!temp.BufferCreate(OpenCL)) return false; if(!FF_Tensors.Add(temp)) return false; //--- Initialize Feed Forward 2 if(i == iLayers - 1) { if(!FF_Tensors.Add(d == 0 ? Output : Gradient)) return false; continue; } temp = new CBufferFloat(); if(CheckPointer(temp) == POINTER_INVALID) return false; if(!temp.BufferInit(out, 0)) return false; if(!temp.BufferCreate(OpenCL)) return false; if(!FF_Tensors.Add(temp)) return false; }
如父级的相应方法一般,我们不会为前馈模块的最终内层创建新的输出缓存区。取而代之,我们直接指向从基本全连接层继承的缓冲区。这些缓冲区用于层间数据交换。而且我们在执行过程中直接写入它们,避免了内部和外部接口之间不必要的数据传送。
在初始化前馈和反向传播数据流的数据缓冲区之后,我们继续初始化权重矩阵。不过,在我们的例子中,适配当前场景状态的 查询、键 和 值 实体的生成,依赖于两个超网络:一个针对与场景无关的(先验)对象知识,另一个针对场景相关(上下文)知识。这些超网络也需要初始化。
此刻,实现策略可能不尽相同。如您所知,与 查询 不同,键 和 值 或许无需在每个内层生成。因此,我们将它们分配到一个单独的张量之中。理论上,我们可为每个相应的权重矩阵创建单独的超网络。不过,该方式在性能方面并不理想。因为它会增加顺序操作的数量。取而代之,我们选择使用统一的超模型并行生成所需的张量,然后将输出分派到相应的数据缓冲区当中。
不过,并非每个内层都需要 键 和 值。因此,考虑到这一点,在如此情况下,简单地利用返回较小结果张量的模型。
听起来很合乎逻辑?太棒了 — 我们转到实现。首先,我们将操作流划为到两个分支,具体取决于是否需要生成 键-值 张量。两个流的执行算法雷同。唯一的区别在于生成的张量大小。
if(i % iLayersToOneKV == 0) { //--- Initilize Scene-Specific layers ss = new CNeuronSceneSpecific(); if(!ss) return false; if(!ss.Init((q_weights + kv_weights), cSceneSpecific.Total(), OpenCL, iWindow, iWindowKey, 4, 2, iUnits, 100, 2, 2, optimization, iBatch)) return false; if(!cSceneSpecific.Add(ss)) return false;
首先,我们配以上下文相关的表示模型操作。于此,我们将创建并初始化之前实现的特定场景的知识模块的动态实例。指向新创建对象的指针存储在 cSceneSpecific 数组之中,其形成条件化上下文模型的一部分。
不过,这里应当注意一个关键的细微差别。特定场景的知识模块建立在交叉注意力模块之上,其取所分析场景的状态作为输入。它返回一个相应维度的张量,丰富了与上下文相关的知识。问题是输入张量的大小或许与目标权重矩阵的所需维度不匹配。为了解决这个问题,我们引入了一个完全连接缩放层,该层可相应地调整尺寸。
base = new CNeuronBaseOCL(); if(!base) return false; if(!base.Init(0, cSceneSpecific.Total(), OpenCL, (q_weights + kv_weights), optimization, iBatch)) return false; base.SetActivationFunction(TANH); if(!cSceneSpecific.Add(base)) return false;
该缩放层使用双曲正切(tanh)作为其激活函数,它输出 [-1, 1] 范围内的数值。如是结果,有关场景的条件化上下文知识,本质上充当标志机制,指示所分析场景中某些物体的似然性或存在。
对于独立于场景的先验知识模型,我们使用两层 MLP,其结构类似于前面描述的用于维护条件化上下文嵌入的 MLP。
//--- Initilize Scene-Agnostic layers base = new CNeuronBaseOCL(); if(!base) return false; if(!base.Init((q_weights + kv_weights), cSceneAgnostic.Total(), OpenCL, 1, optimization, iBatch)) return false; temp = base.getOutput(); if(!temp.BufferInit(1, 1) || !temp.BufferWrite()) return false; if(!cSceneAgnostic.Add(base)) return false; base = new CNeuronBaseOCL(); if(!base) return false; if(!base.Init(0, cSceneAgnostic.Total(), OpenCL, (q_weights + kv_weights), optimization, iBatch)) return false; if(!cSceneAgnostic.Add(base)) return false; }
如果不需要生成 键-值张量,我们会创建类似的对象,但尺寸更小。
else { //--- Initilize Scene-Specific layers ss = new CNeuronSceneSpecific(); if(!ss) return false; if(!ss.Init(q_weights, cSceneSpecific.Total(), OpenCL, iWindow, iWindowKey, 4, 2, iUnits, 100, 2, 2, optimization, iBatch)) return false; if(!cSceneSpecific.Add(ss)) return false; base = new CNeuronBaseOCL(); if(!base) return false; if(!base.Init(0, cSceneSpecific.Total(), OpenCL, q_weights, optimization, iBatch)) return false; base.SetActivationFunction(TANH); if(!cSceneSpecific.Add(base)) return false; //--- Initilize Scene-Agnostic layers base = new CNeuronBaseOCL(); if(!base) return false; if(!base.Init(q_weights, cSceneAgnostic.Total(), OpenCL, 1, optimization, iBatch)) return false; temp = base.getOutput(); if(!temp.BufferInit(1, 1) || !temp.BufferWrite()) return false; if(!cSceneAgnostic.Add(base)) return false; base = new CNeuronBaseOCL(); if(!base) return false; if(!base.Init(0, cSceneAgnostic.Total(), OpenCL, q_weights, optimization, iBatch)) return false; if(!cSceneAgnostic.Add(base)) return false; }
对于多头注意力结果数据缩放层和 FeedForward 模块,我们用随机参数初始化的训练参数的正则矩阵。
//--- Initilize Weights0 temp = new CBufferFloat(); if(CheckPointer(temp) == POINTER_INVALID) return false; if(!temp.Reserve(w0)) return false; float k = (float)(1 / sqrt(iWindow + 1)); for(uint w = 0; w < w0; w++) { if(!temp.Add(GenerateWeight() * 2 * k - k)) return false; } if(!temp.BufferCreate(OpenCL)) return false; if(!FF_Weights.Add(temp)) return false; //--- Initilize FF Weights temp = new CBufferFloat(); if(CheckPointer(temp) == POINTER_INVALID) return false; if(!temp.Reserve(ff_1)) return false; for(uint w = 0; w < ff_1; w++) { if(!temp.Add(GenerateWeight() * 2 * k - k)) return false; } if(!temp.BufferCreate(OpenCL)) return false; if(!FF_Weights.Add(temp)) return false; //--- temp = new CBufferFloat(); if(CheckPointer(temp) == POINTER_INVALID) return false; if(!temp.Reserve(ff_2)) return false; k = (float)(1 / sqrt(4 * iWindow + 1)); for(uint w = 0; w < ff_2; w++) { if(!temp.Add(GenerateWeight() * 2 * k - k)) return false; } if(!temp.BufferCreate(OpenCL)) return false; if(!FF_Weights.Add(temp)) return false;
我们还添加了力矩缓冲区,用于优化创建的训练参数矩阵。力矩缓冲区的数量由指定的参数优化方法决定。
//--- for(int d = 0; d < (optimization == SGD ? 1 : 2); d++) { temp = new CBufferFloat(); if(CheckPointer(temp) == POINTER_INVALID) return false; if(!temp.BufferInit((d == 0 || optimization == ADAM ? w0 : iWindow), 0)) return false; if(!temp.BufferCreate(OpenCL)) return false; if(!FF_Weights.Add(temp)) return false; //--- Initilize FF Weights temp = new CBufferFloat(); if(CheckPointer(temp) == POINTER_INVALID) return false; if(!temp.BufferInit((d == 0 || optimization == ADAM ? ff_1 : 4 * iWindow), 0)) return false; if(!temp.BufferCreate(OpenCL)) return false; if(!FF_Weights.Add(temp)) return false; temp = new CBufferFloat(); if(CheckPointer(temp) == POINTER_INVALID) return false; if(!temp.BufferInit((d == 0 || optimization == ADAM ? ff_2 : iWindow), 0)) return false; if(!temp.BufferCreate(OpenCL)) return false; if(!FF_Weights.Add(temp)) return false; } }
初始化所有指定对象成功后,我们转入循环的下一次迭代,于其中,我们将为后续的内层创建类似的对象。
在初始化方法的末尾,我们初始化存储临时数据的辅助缓冲区,并将操作的逻辑结果返回给调用程序。
if(!Temp.BufferInit(MathMax((num_q + num_kv)*iWindow, out), 0)) return false; if(!Temp.BufferCreate(OpenCL)) return false; //--- SetOpenCL(OpenCL); //--- return true; }
复杂的结构和大量的对象,导致很难理解正在构建的算法。甚至,在实现前馈和反向传播通验方法期间,我们需要仔细监控对象之间的信息流和数据传输。我们将从 feedForward 方法的构造开始。
bool CNeuronMLMHSceneConditionAttention::feedForward(CNeuronBaseOCL *NeuronOCL) { CNeuronBaseOCL *ss = NULL, *sa = NULL; CBufferFloat *q_weights = NULL, *kv_weights = NULL, *q = NULL, *kv = NULL;
在方法参数中,我们接收指向源数据对象的指针。不过,我们不会检查接收索引的相关性。因为我们在这个阶段不打算直接访问源数据对象。不过,我们将做一些准备工作,在此期间,我们将创建局部变量,临时存储指向各种对象的指针。然后我们将创建一个循环来遍历模块的内层。
for(uint i = 0; i < iLayers; i++) { //--- Scene-Specific ss = cSceneSpecific[i * 2]; if(!ss.FeedForward(NeuronOCL)) return false; ss = cSceneSpecific[i * 2 + 1]; if(!ss.FeedForward(cSceneSpecific[i * 2])) return false;
在循环内,我们先从使用之前定义的超网络开始,生成构造 "查询"、"键" 和 "值" 实体所需的权重系数。
首先,基于从调用程序收到的场景状态描述,我们生成上下文相关参数的矩阵。如早前所述,用特定上下文的知识充实这个张量之后,我们重新缩放它,以匹配所需参数矩阵的维度。
同时,我们生成与场景无关的参数矩阵。
//--- Scene-Agnostic sa = cSceneAgnostic[i * 2 + 1]; if(bTrain && !sa.FeedForward(cSceneAgnostic[i * 2])) return false;
注意,与场景无关的参数矩阵仅在模型训练期间生成。在操作期间,矩阵维持静止。故而,无需为每次前馈通验重新生成它。
接下来,我们对两个矩阵进行元素级乘法。结果是最终的权重矩阵,然后将其分派到先前已初始化的数据缓冲区之中。重点要注意,这是生成的一个单一权重矩阵,分为两部分。一个部分用于创建 查询 实体。第二部分用于 键 和 值 实体。不过,"键" 和 "值" 不一定在每个层上都形成。因此,我们根据是否需要生成 键-值 张量来为操作分流。
在如此行事之前,我们会运作一些初步设置。具体来说,我们将指向当前内部层的输入数据对象指针传输到局部变量之中。
CBufferFloat *inputs = (i == 0 ? NeuronOCL.getOutput() : FF_Tensors.At(6 * i - 4));
在此,我们存储指向内层输入的指针。这意味着从外部程序接收的指针仅传递到第一个内层。对于所有后续层,我们使用来自前一个内层的输出。
我们还将指向权重参数和 "查询" 张量的数据缓冲区指针存储在局部变量之中,无论我们遵循两条处理路径中的哪一条,都会用到这两条指针。
q_weights = QKV_Weights[i * 2]; q = QKV_Tensors[i * 2];
如果需要形成 键-值 张量,我们首先对上面形成的两个权重矩阵进行元素乘法。我们将操作结果写入临时数据存储缓冲区。
if(i % iLayersToOneKV == 0) { if(IsStopped() || !ElementMult(ss.getOutput(), sa.getOutput(), GetPointer(Temp))) return false;
我们将指向权重和 键-值 实体数值缓冲区的指针保存在局部变量之中。
kv_weights = KV_Weights[(i / iLayersToOneKV) * 2]; kv = KV_Tensors[(i / iLayersToOneKV) * 2];
然后,我们跨两个数据缓冲区分派权重参数的公用张量。
if(IsStopped() || !DeConcat(q_weights, kv_weights, GetPointer(Temp), iHeads, 2 * iHeadsKV, iWindow * iWindowKey)) return false; if(IsStopped() || !MatMul(inputs, kv_weights, kv, iUnits, iWindow, 2 * iHeadsKV * iWindowKey, 1)) return false; }
之后,我们将当前内层的初始数据张量矩阵乘以获得的权重系数矩阵,形成一个 键-值 实体的张量。
如果不需要形成 键-值 实体张量,我们仅执行两个参数矩阵的元素级乘法运算,并将结果写入相应的数据缓冲区。在这种情况下,我们的超网络仅形成 查询 实体的权重矩阵。
else { if(IsStopped() || !ElementMult(ss.getOutput(), sa.getOutput(), q_weights)) return false; }
在任何情况下都会执行查询实体数值张量的形成。因此,我们以普通流实现此操作。
if(IsStopped() || !MatMul(inputs, q_weights, q, iUnits, iWindow, iHeads * iWindowKey, 1)) return false;
在该阶段,超网络到注意力算法的实现就完成了。随后是我们已经熟悉的机制:自注意力。首先,我们判定多头注意力的结果。
//--- Score calculation and Multi-heads attention calculation CBufferFloat *temp = S_Tensors[i * 2]; CBufferFloat *out = AO_Tensors[i * 2]; if(IsStopped() || !AttentionOut(q, kv, temp, out)) return false;
然后,我们降低结果输出张量的维数。
//--- Attention out calculation temp = FF_Tensors[i * 6]; if(IsStopped() || !ConvolutionForward(FF_Weights[i * (optimization == SGD ? 6 : 9)], out, temp, iWindowKey * iHeads, iWindow, None)) return false;
之后,我们将自注意力模块的结果与原始数据相加,并对结果张量进行归一化。
//--- Sum and normilize attention if(IsStopped() || !SumAndNormilize(temp, inputs, temp, iWindow, true)) return false;
接下来,数据经 FeedForward 模块通验。
//--- Feed Forward inputs = temp; temp = FF_Tensors[i * 6 + 1]; if(IsStopped() || !ConvolutionForward(FF_Weights[i * (optimization == SGD ? 6 : 9) + 1], inputs, temp, iWindow, 4 * iWindow, LReLU)) return false; out = FF_Tensors[i * 6 + 2]; if(IsStopped() || !ConvolutionForward(FF_Weights[i * (optimization == SGD ? 6 : 9) + 2], temp, out, 4 * iWindow, iWindow, activation)) return false;
然后我们对数据求和,并归一化。
//--- Sum and normilize out if(IsStopped() || !SumAndNormilize(out, inputs, out, iWindow, true)) return false; } //--- result return true; }
我们对所有内层重复这些操作。在我们完成所有循环迭代后,返回方法操作执行的逻辑结果至调用程序 。
我希望在该阶段,您能明白我们的类算法是如何工作的。但是,在反向传播通验期间,还有另一个与误差梯度分布相关的细微差别 - 其算法在 calcInputGradients 方法中实现。
bool CNeuronMLMHSceneConditionAttention::calcInputGradients(CNeuronBaseOCL *prevLayer) { if(CheckPointer(prevLayer) == POINTER_INVALID) return false;
在方法参数中,如常,我们收到一个指向前一层对象的指针,其中我们必须根据原始数据对最终结果的影响,将误差梯度传播至该对象。在方法主体中,我们立即检查接收指针的相关性。之后,我们创建若干个局部变量来临时存储指向对象的指针。
CBufferFloat *out_grad = Gradient; CBufferFloat *kv_g = KV_Tensors[KV_Tensors.Total() - 1]; CNeuronBaseOCL *ss = NULL, *sa = NULL;
然后我们组织一个遍历内层的逆向循环。
for(int i = int(iLayers - 1); (i >= 0 && !IsStopped()); i--) { if(i == int(iLayers - 1) || (i + 1) % iLayersToOneKV == 0) kv_g = KV_Tensors[(i / iLayersToOneKV) * 2 + 1];
如您所知,梯度反向传播算法是前馈通验的镜像,所有操作都以相反的顺序执行。出于这个原因,我们在模块的内层构建了一个逆向迭代循环。
我要提醒一下,后半部分的前馈通验操作直接复制了父类中的相应逻辑。相较之,我们重用父类中的相应方法来开始梯度反向传播方法的前半部分。
我们首先通过 FeedForward 模块反向传播误差梯度。
//--- Passing gradient through feed forward layers if(IsStopped() || !ConvolutionInputGradients(FF_Weights[i * (optimization == SGD ? 6 : 9) + 2], out_grad, FF_Tensors[i * 6 + 1], FF_Tensors[i * 6 + 4], 4 * iWindow, iWindow, None)) return false; CBufferFloat *temp = FF_Tensors[i * 6 + 3]; if(IsStopped() || !ConvolutionInputGradients(FF_Weights[i * (optimization == SGD ? 6 : 9) + 1], FF_Tensors[i * 6 + 4], FF_Tensors[i * 6], temp, iWindow, 4 * iWindow, LReLU)) return false;
之后,我们将两个信息流的误差梯度相加。
//--- Sum and normilize gradients if(IsStopped() || !SumAndNormilize(out_grad, temp, temp, iWindow, false)) return false;
然后我们通过 多头自注意力 模块传播误差梯度。
//--- Sum and normilize gradients if(IsStopped() || !SumAndNormilize(out_grad, temp, temp, iWindow, false)) return false; out_grad = temp; //--- Split gradient to multi-heads if(IsStopped() || !ConvolutionInputGradients(FF_Weights[i * (optimization == SGD ? 6 : 9)], out_grad, AO_Tensors[i * 2], AO_Tensors[i * 2 + 1], iWindowKey * iHeads, iWindow, None)) return false; //--- Passing gradient to query, key and value sa = cSceneAgnostic[i * 2 + 1]; ss = cSceneSpecific[i * 2 + 1]; if(i == int(iLayers - 1) || (i + 1) % iLayersToOneKV == 0) { if(IsStopped() || !AttentionInsideGradients(QKV_Tensors[i * 2], QKV_Tensors[i * 2 + 1], KV_Tensors[(i / iLayersToOneKV) * 2], kv_g, S_Tensors[i * 2], AO_Tensors[i * 2 + 1])) return false; } else { if(IsStopped() || !AttentionInsideGradients(QKV_Tensors[i * 2], QKV_Tensors[i * 2 + 1], KV_Tensors[i / iLayersToOneKV * 2], GetPointer(Temp), S_Tensors[i * 2], AO_Tensors[i * 2 + 1])) return false; if(IsStopped() || !SumAndNormilize(kv_g, GetPointer(Temp), kv_g, iWindowKey, false, 0, 0, 0, 1)) return false; }
在此,应特别注意误差梯度如何传播到 键-值 张量。细微差别在于按给定张量从受影响的所有内层收集误差梯度。该算法的更详细解释在专门针对父类的文章中提供。
然后,我们继续沿主要信息流将误差梯度反向传播到输入数据的级别。
CBufferFloat *inp = NULL; if(i == 0) { inp = prevLayer.getOutput(); temp = prevLayer.getGradient(); } else { temp = FF_Tensors.At(i * 6 - 1); inp = FF_Tensors.At(i * 6 - 4); } if(IsStopped() || !MatMulGrad(inp, temp, QKV_Weights[i * 2], QKV_Weights[i * 2 + 1], QKV_Tensors[i * 2 + 1], iUnits, iWindow, iHeads * iWindowKey, 1)) return false; //--- Sum and normilize gradients if(IsStopped() || !SumAndNormilize(out_grad, temp, temp, iWindow, false, 0, 0, 0, 1)) return false;
我们还根据误差梯度对模型整体结果的影响,将误差梯度传播到超网络。
//--- if((i % iLayersToOneKV) == 0) { if(IsStopped() || !MatMulGrad(inp, GetPointer(Temp), KV_Weights[i / iLayersToOneKV * 2], KV_Weights[i / iLayersToOneKV * 2 + 1], KV_Tensors[i / iLayersToOneKV * 2 + 1], iUnits, iWindow, 2 * iHeadsKV * iWindowKey, 1)) return false; if(IsStopped() || !SumAndNormilize(GetPointer(Temp), temp, temp, iWindow, false, 0, 0, 0, 1)) return false; if(!Concat(QKV_Weights[i * 2 + 1], KV_Weights[i / iLayersToOneKV * 2 + 1], ss.getGradient(), iHeads, 2 * iHeadsKV, iWindow * iWindowKey)) return false; if(!ElementMultGrad(ss.getOutput(), ss.getGradient(), sa.getOutput(), sa.getGradient(), ss.getGradient(), ss.Activation(), sa.Activation())) return false; } else { if(!ElementMultGrad(ss.getOutput(), ss.getGradient(), sa.getOutput(), sa.getGradient(), QKV_Weights[i * 2 + 1], ss.Activation(), sa.Activation())) return false; } if(i > 0) out_grad = temp; }
此后,我们转入循环的下一次迭代,遍历内层。
重点要注意,在主操作循环中,我们仅把误差梯度向下传播到超网络,但我们未再通过它们传播。这引入了一些细微差别。首先,我们独立于场景的超网络仅由两层组成。第一个是静态的,始终输出常量值 1。第二个包含可训练的参数,并返回实际结果。在主操作流中,我们仅把误差梯度传递至第二层。将梯度传播到静态的第一层将毫无意义。当然,这是一个具体情况。如果超模型有更多层,我们需要为包含可训练参数的所有层实现梯度反向传播逻辑。
第二个细微差别与场景相关的超网络有关。在该实现中,所有参数都是基于从调用程序收到的场景描述生成的。如是结果,整个误差梯度必须传播到该级别。为了维持主数据流的完整性,我们选择在单独的循环中处理该模型的梯度传播。再有,这是一个特定的实现选择。如果我们要从不同的来源(例如,前面的内层输出)派生场景描述,则需要相应地传播渐变。
我们回到梯度反向传播方法的算法。在完成内层的逆向迭代后,我们将表示输入数据如何通过主数据流影响模型输出的结果存储在前一层的梯度缓冲区之中。现在,我们必须将超网络的贡献添加到该缓冲区当中。为此,我们首先将指向前一层梯度缓冲区的指针存储在局部变量之中。然后,我们临时将指向当前层对象的辅助数据缓冲区的指针分配给当前层对象。
CBufferFloat *inp_grad = prevLayer.getGradient(); if(!prevLayer.SetGradient(GetPointer(Temp), false)) return false;
现在,我们可将误差梯度向下传播到前一层,而不必担心丢失以前保存的数据。我们创建一个循环,迭代上下文相关的超网络的对象;在其主体中,我们将误差梯度传播到源数据层级别。在每次迭代中,我们将当前结果添加到先前累积的梯度当中。
for(int i = int(iLayers - 2); (i >= 0 && !IsStopped()); i -= 2) { ss = cSceneSpecific[i]; if(IsStopped() || !ss.calcHiddenGradients(cSceneSpecific[i + 1])) return false; if(IsStopped() || !prevLayer.calcHiddenGradients(ss, NULL)) return false; if(IsStopped() || !SumAndNormilize(prevLayer.getGradient(), inp_grad, inp_grad, iWindow, false, 0, 0, 0, 1)) return false; }
在循环操作成功执行后,我们将指向其缓冲区的指针返回到上一层的对象,其中包含所有信息流中已经累积的误差梯度。
if(!prevLayer.SetGradient(inp_grad, false)) return false; //--- return true; }
误差梯度是完全分布的,我们把误差梯度分布方法操作的逻辑执行结果返回给调用程序。我建议您自行熟悉更新模型参数的方法。您可在附件中找到该类、及其所有方法的完整代码。
2.3构造完整的 HyperDet3D 算法
早前,我们构建了 HyperDet3D 算法的各个组件。现在是时候将所有东西整合到一个单一的、有凝聚力的结构中了。虽然这看似相对直截了当,但有一些重要的细微差别值得强调。
对于该实验,我选择基于上一篇文章中讨论的 Pointformer 架构进行集成。这里的关键修改是将全局注意力模块替换为我们新构建的 MSA 模块。该操作非常简单。特别是因为我们保留了所有方法参数,包括类初始化方法。不过,有一个问题:原始 CNeuronPointFormer 类中的所有对象都声明为静态。如是结果,我们不能直接从类继承,并修改其内部对象的类型。因此,我们创建一个类的副本,在其中调整必要的内部对象的类型,以此融合新功能。新类结构如下所示。
class CNeuronHyperDet : public CNeuronPointNet2OCL { protected: CNeuronMLMHSparseAttention caLocalAttention[2]; CNeuronMLCrossAttentionMLKV caLocalGlobalAttention[2]; CNeuronMLMHSceneConditionAttention caGlobalAttention[2]; CNeuronLearnabledPE caLocalPE[2]; CNeuronLearnabledPE caGlobalPE[2]; CNeuronBaseOCL cConcatenate; CNeuronConvOCL cScale; //--- CBufferFloat *cbTemp; //--- virtual bool feedForward(CNeuronBaseOCL *NeuronOCL) override ; //--- virtual bool calcInputGradients(CNeuronBaseOCL *NeuronOCL) override; virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL) override; public: CNeuronHyperDet(void) {}; ~CNeuronHyperDet(void) { delete cbTemp; } //--- virtual bool Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint units_count, uint output, bool use_tnets, ENUM_OPTIMIZATION optimization_type, uint batch) override; //--- virtual int Type(void) override const { return defNeuronHyperDet; } //--- 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; };
我们不会深入研究该类的方法,在于它们都是直接从 CNeuronPointFormer 类复制相应的方法创建的。
模型架构以及所有互动和训练脚本也借鉴了上一篇文章。因此,我们现在不再赘述它们。附件中提供了本文准备时用到的所有程序的完整源代码。
3. 测试
我们进行了广泛的工作,来实现我们自己对 HyperDet3D 方法作者提出的方法解释。现在是时候转到我们文章的最后一部分了。在此,我们将训练和测试包含所简述技术的模型。
如常,为了训练模型,我们采用 EURUSD 金融产品整个 2023 年的真实历史数据,以及 H1 时间帧。所有指标参数均按其默认值设置。训练过程本身遵循上一篇文章中概述的确切算法。故此,我们将只关注测试经过训练的参与者政策的结果,如下所示。

训练后的模型依据 2024 年 1 月的历史数据进行了测试,这些数据并非训练数据集的一部分。在此期间,该模型执行了 41 笔交易,其中 56% 以盈利了结。值得注意的是,最大的盈利交易是最大亏损交易的 2.4 倍,平均盈利交易比平均亏损交易高出 67%。这些结果导致盈利因子为 2.14,锋锐比率为 20.65。
总体上,该模型在测试期间实现了 1% 的利润,而净值的最大回撤不超过 0.34%。余额回撤甚至更低。净值曲线显示余额稳步增长,账户敞口保持在 1-2% 之间。
结果的总体印象是正面的。该模型展现出前景。然而,较短的测试区间、及有限的交易数量,令我们无法对模型的长期稳定性得出结论。在用于实时交易之前,需要依据更大的历史数据集进一步训练、和更全面的测试。
结束语
在本文中,我们探讨了 HyperDet3D 方法,其将条件化场景超网络集成到变换器架构之中,从而嵌入先验知识。这令模型能够根据场景信息动态调整探测器参数,从而有效地适应物体检测任务中的不同场景。这令该系统更加通用和强大。
在实践部分,我们利用 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/15859
注意: MetaQuotes Ltd.将保留所有关于这些材料的权利。全部或部分复制或者转载这些材料将被禁止。
本文由网站的一位用户撰写,反映了他们的个人观点。MetaQuotes Ltd 不对所提供信息的准确性负责,也不对因使用所述解决方案、策略或建议而产生的任何后果负责。
交易中的神经网络:探索局部数据结构
创建一个基于日波动区间突破策略的 MQL5 EA
Connexus助手(第五部分):HTTP方法和状态码
让新闻交易轻松上手(第4部分):性能增强