
交易中的神经网络:点云的层次化特征学习
概述
点的几何集表示欧几里德空间中的点集合。作为一个集合,这样的数据必须保持其元素排列的不变性。甚至,距离量值定义了局部邻域,这些邻域或许会表现出不同的属性。举例,点密度和其它属性在不同区域之间可能是异质的。
在上一篇文章中,我们探讨了 PointNet 方法,其核心思想是学习每个点的空间编码,随后将所有单独的表示聚合到点云的全局签名之中。不过,PointNet 不可捕获局部结构。是的,使用局部结构已被证明对于卷积架构的成功至关重要。卷积模型处理排布在规则网格上的输入数据,并且可以沿着多个分辨率层次按越来越大的比例逐步捕获对象。在较低水平上,神经元具有较小的感知域,而在较高水平上,它们包含较大的区域。跨层次抽象局部形态的能力强化了普适性。
类似方式也已应用在 PointNet++ 模型,在论文《PointNet++:在量值空间中基于点集的深度层次化特征学习》中有所阐述。PointNet++ 的核心思想是基于底层空间中的距离量值将点集分区为重叠的局部区域。类似于卷积网络,PointNet++ 提取局部特征,从小区域捕获细粒度的几何结构。然后将这些局部结构组群为更大的元素,并加以处理,以便派生出更高级别的表示。该过程重复迭代,直至获得整个点集的特征。
在设计 PointNet++ 时,作者解决了两个关键挑战:对点集进行分区,并通过局部化特征学习抽象出点集或局部特征。这些挑战是相互依赖的,因为对点集进行分区需要跨分段维护共享结构,从而实现局部特征的共享权重学习,类似于卷积模型。作者选择 PointNet 作为局部学习单元,在于它是一个有效处理无序点集和提取语义特征的架构。此外,这种架构对输入数据中的噪声很健壮。作为一个基本构建模块,PointNet 将局部点或对象集抽象为更高级别的表示。在该框架中,PointNet++ 递归应用 PointNet 至输入数据的嵌套子分段。
剩下的一个挑战是创建点云的重叠分区的方法。每个区域都定义为欧几里德空间中的邻域球体,由诸如质心位置和比例等参数来表征。为了确保整个集合的均匀覆盖,使用最远点采样算法,从原始点中选择质心。相较于以固定步幅扫描空间的体积卷积模型,PointNet++ 中的局部感知域取决于输入数据和距离两个量值。这提升了它们的效率。
1. PointNet++ 算法
PointNet 架构用单个 MaxPooling 操作来聚合整个点集。对比之下,PointNet++ 的作者引入了一种层次化架构,即沿多个层次化级别逐步抽象化局部区域。
提议的层次化结构由几个预定义的抽象级别组成。在每个级别,对点云进行处理和抽象,以便创建元素更少的新数据集。每个抽象级别都包含三个关键层:采样层、组群层、和 PointNet 层。采样层从原始点云中选择点的子集,定义局部区域的质心。然后,组群层 通过识别每个质心周围的 “相邻” 点来形成局部点集。最后,PointNet 层 应用一个迷你-PointNet 将局部形态编码为特征向量。
抽象级别采用大小为 N×(d+C) 的输入矩阵,其中 N 是点的数量,d 是坐标的维数,C 是特征的维数。它输出一个大小为 N′×(d+C′) 的矩阵,其中 N′ 是子采样点的数量,C′ 是封装局部上下文的新特征向量维数。
PointNet++ 的作者提议迭代最远点采样(FPS)来选择质心点的子集。相比随机采样,该方法能更好地覆盖整个点云,同时维护相同数量的质心。与独立于数据分布扫描向量空间的卷积网络不同,这种采样策略生成的感知域本质上是依赖于数据的。
组群层取大小为 N×(d+C) 的点云,和一组大小为 N′×d 的质心坐标作为输入。输出由大小为 N′×K×(d+C) 的组群点集组成,其中每个群对应于一个局部区域,K 是质心邻域内点的数量。
注意,K 因组群而异,但后续的 PointNet 层 可把数量灵活的点转换至表示局部区域的固定长度特征向量。
在卷积神经网络(CNN)中,像素的局部邻域由定义好的曼哈顿(Manhattan)距离(内核大小)之内的相邻像素组成。在一个点云当中,如果量值空间存在点,则邻域关系由距离量值测定。
在组群过程期间,模型将识别位于查询点的预定义半径内的所有点(以 K 为上限作为超参数)。
在 PointNet 层 中,输入由数据大小为 N′×K×(d+C) 的 N′ 局部区域组成。每个局部区域最终被抽象为其质心、及对其周围邻域编码的相应局部特征。得到的张量大小为 N′×(d+C)。
每个局部区域内的点坐标首先转换至相对于其质心的局部坐标系:
对于i = 1, 2,…, K ad j = 1, 2,…, d,其中 表示质心的坐标。
PointNet++ 的作者使用 PointNet 作为学习局部形态的基本构建模块。通过使用单个点的相对坐标特征,该模型可以有效地捕获局部区域内点之间的关系。
通常,点云在不同区域密度不均匀。这种异质性在学习点集特征时是一个重大问题。在密集采样区域中学习的特征,或许无法很好地普适到种群稀少的区域。由此,在稀疏点云上训练的模型,或许在识别细粒度的局部结构时遇挫。
理想化,点云处理应尽可能精确,以便在密集采样区域中捕获最精细的详情。不过,在点密度较低的区域,这样详细分析的效率低下,因为数据不足可能会令局部形态失真。在这些情况下,必须考虑采用更广泛的邻域来检测更大规模的结构。为了解决这个问题,PointNet++ 的作者提出了密度自适应 PointNet 层,其旨在聚合来自多个尺度的特征,同时参考点密度的变化。
PointNet++ 中的每个抽象级别都提取局部形态的多个尺度,并基于局部点密度智能组合它们。原始论文阐述了两种类型的密度自适应层。
一种直截了当、且有效的捕获多尺度形态的方式涉及应用不同尺度的多个组群层,并分配相应的 PointNet 模块来提取每个尺度的特征。然后,生成的多尺度表达将组合成一个统一的特征。
网络学习合并多尺度特征的最优策略。这是通过随机放置输入点,并为每个实例分配概率来达成的。
上述方式需要大量的计算资源,因为它对每个质心点在大规模邻域内应用局部 PointNet 操作。为了减轻这种计算开销,同时保留自适应聚合信息的能力,作者提出了一种基于连接两个特征向量的替代特征融合方法。按给定的抽象级别,聚合每个较低级别 Li-1 子区域的特征,衍生出一个向量。第二个向量是通过使用单个 PointNet 模块直接处理局部区域内的所有原始点而获得的。
当局部区域密度较低时,第一个向量的可靠性可能低于第二个向量,是因用于特征计算的子区域包含的点更少,并且受稀疏采样的影响更大。在这种情况下,第二个向量应拥有更高的权重。相较之,当点密度较高时,第一个向量会提供更精细的详情,在于它能在较低级别以更高的分辨率递归检查局部结构。
该方法的计算效率更高,在于它避免了在最低级别计算大规模邻域的特征。
在抽象层中,原始点集经受再次采样。不过,在语义点标记等分段任务中,期望获取所有原始点的每点特征。一种可能的解决方案是跨所有抽象级别,以所有点为质心采样,但这会显著增加计算成本。另一种途径是将对象从子采样点传播到原始点。
作者对 PointNet++ 方法的可视化如下所示。
2. 利用 MQL5 实现
在回顾了 PointNet++ 方法的理论层面之后,我们现在转到本文的实践部分,其中我们利用 MQL5 实现对所提议方法的解释。值得注意的是,我们的实现在某些方面与上述原始版本不同。但特事特例。
我们将工作划分为两个主要部分。首先,我们将创建一个局部子采样层,该层将集成前面讨论的采样和组群层。然后,我们将开发一个高等级类,它将各个分量汇编到一个完整的 PointNet++ 算法。
2.1扩展 OpenCL 程序
局部子采样算法将在 CNeuronPointNet2Local 类中实现。然而,在我们开始研究这个类之前,我们必须首先扩展 OpenCL 程序的功能。
开始在即,我们将创建 CalcDistance 内核,它将计算所分析点云中点之间的距离。
重点需注意,距离将在多维特征空间中计算,其中每个点都由一个特征向量表示。内核的输出将是一个 N×N 矩阵,对角线上的值为零。
内核参数将包括指向两个数据缓冲区(一个用于输入数据,一个用于存储结果)的指针,和一个指定点特征向量维数的常量。
__kernel void CalcDistance(__global const float *data, __global float *distance, const int dimension ) { const size_t main = get_global_id(0); const size_t slave = get_local_id(1); const int total = (int)get_local_size(1);
在内核内部,我们标识任务空间内的线程。
我们的预期输出是一个方阵。因此,我们定义了一个相应大小的二维任务空间。这可确保每个条单独线程能计算结果矩阵的单个元素。
此刻,我们引入了与原始 PointNet++ 算法的第一个偏差。我们不会迭代检测局部区域的质心。代之,我们的实现将云中的每个点视为一个质心。为了实现区域大小的可适性,我们归一化云中每个点的距离。归一化距离需要各个线程之间的数据交换。为了促成这一点,我们沿着结果矩阵的行规划局部工作组。
为了在工作组内进行高效的数据交换,我们创建了一个局部数组。
__local float Temp[LOCAL_ARRAY_SIZE]; int ls = min((int)total, (int)LOCAL_ARRAY_SIZE);
然后我们判定数据缓冲区中所需元素的偏移量常量。
const int shift_main = main * dimension; const int shift_slave = slave * dimension; const int shift_dist = main * total + slave;
之后,我们创建一个循环来计算多维空间中两个对象之间的距离。
//--- calc distance float dist = 0; if(main != slave) { for(int d = 0; d < dimension; d++) dist += pow(data[shift_main + d] - data[shift_slave + d], 2.0f); }
请注意,仅对非对角线元素执行计算。这是因为从一个点到它自己的距离等于 “0”。故此,我们不会将资源浪费在不必要的计算上。
下一步是判定工作组内的最大距离。首先,我们将单个模块的最大值收集到一个局部数组之中。
//--- Look Max for(int i = 0; i < total; i += ls) { if(!isinf(dist) && !isnan(dist)) { if(i <= slave && (i + ls) > slave) Temp[slave - i] = max((i == 0 ? 0 : Temp[slave - i]), dist); } else if(i == 0) Temp[slave] = 0; barrier(CLK_LOCAL_MEM_FENCE); }
然后我们在数组中找到最大值。
int count = ls; do { count = (count + 1) / 2; if(slave < count && (slave + count) < ls) { if(Temp[slave] < Temp[slave + count]) Temp[slave] = Temp[slave + count]; Temp[slave + count] = 0; } barrier(CLK_LOCAL_MEM_FENCE); } while(count > 1);
找到所分析点的最大值之后,我们将其除以上面已计算的距离数量。结果如是,点之间的所有距离都将在 [0, 1] 范围内归一化。
//--- Normalize if(Temp[0] > 0) dist /= Temp[0]; if(isinf(dist) || isnan(dist)) dist = 1; //--- result distance[shift_dist] = dist; }
我们将计算出的数值保存在全局结果缓冲区的相应元素之中。
当然,我们识别出所分析两点之间的最大距离肯定会有所不同。通过对不同尺度中的数值进行归一化,我们会失去这种差异。然而,而这正是令感知域能够适应。
如果所分析的点位于云的密集区域之内,则离它最远的点通常位于云的边缘之一。相较之,如果所分析的点位于云的边缘,则最远的点位于对立的边界。在第二种情况下,点之间的距离会更大。因此,第二种情况下的感知域会更大。
我们还假设云中的点密度高于云边缘的点密度。有鉴于此,增加云外围的感知域是确保提取有意义特征的合理方法。
PointNet++ 的作者建议计算相对于其质心的局部点位移,然后将迷你-PointNet 应用到这些局部子集。然而,尽管它看起来很简单,但该方法存在一个重大的实现问题。
如前所述,每个局部区域中的元素数量各不相同,并且事先是未知的。这引发了有关缓冲区分配的问题。一种可能的解决方案是设置每个感知域的最大点数,并分配一个容量过剩的缓冲区。/不过,这将导致更高的内存消耗,及增加计算复杂性。结果如是,训练变得更加困难,模型性能降低。
代之,我们采用了一种更简单、更通用的方法。我们剔除了局部位移的计算。为了训练点特征,我们对所有元素使用单一权重矩阵,类似于原版 PointNet。不过,MaxPooling 可以在感知域中实现。为了达成这一点,我们创建了一个新的内核 FeedForwardLocalMax,它以三个缓冲区指针作为参数:点特征矩阵、归一化距离矩阵、和结果缓冲区。此外,我们还引入了一个感知域半径常数。
__kernel void FeedForwardLocalMax(__global const float *matrix_i, __global const float *distance, __global float *matrix_o, const float radius ) { const size_t i = get_global_id(0); const size_t total = get_global_size(0); const size_t d = get_global_id(1); const size_t dimension = get_global_size(1);
我们计划在二维任务空间之中执行内核。在第一个维度中,我们指示点云中的元素数量,在第二个维度中,我们则是一个元素的特征维度。在内核主体中,我们立即在任务空间的两个维度中识别当前线程。在这种情况下,每个线程都独立工作,故此我们不需要创建工作组,及在线程之间交换数据。
接下来,我们在数据缓冲区中定义偏移常量。
const int shift_dist = i * total; const int shift_out = i * dimension + d;
然后我们创建一个循环来判定最大值。
float result = -3.402823466e+38; for(int k = 0; k < total; k++) { if(distance[shift_dist + k] > radius) continue; int shift = k * dimension + d; result = max(result, matrix_i[shift]); } matrix_o[shift_out] = result; }
注意,在检查下一个元素值之前,我们首先验证它是否落在云中对应点的感知域内。
一旦循环迭代完毕,我们将计算的数值存储在结果缓冲区之中。
类似地,我们实现了反向传播内核 CalcInputGradientLocalMax,它将误差梯度分派给相应的元素。前馈和反向传播通验内核分享众多相似之处。我鼓励您独立审阅它们。您可在附件中找到完整的内核代码。现在,我们继续主程序实现。
2.2局部子采样类
在完成了 OpenCL 端的准备工作后,我们现在转向开发局部子采样类。在实现 OpenCL 内核时,我们已经触及了算法设计的基本原则。不过,随着我们继续实现 CNeuronPointNet2Local 类,我们将更详细地探讨这些原则,并验证它们在代码中的实际实现。新类结构如下所示。
class CNeuronPointNet2Local : public CNeuronConvOCL { protected: float fRadius; uint iUnits; //--- CBufferFloat cDistance; CNeuronConvOCL cFeatureNet[3]; CNeuronBatchNormOCL cFeatureNetNorm[3]; CNeuronBaseOCL cLocalMaxPool; CNeuronConvOCL cFinalMLP; //--- virtual bool CalcDistance(CNeuronBaseOCL *NeuronOCL); virtual bool LocalMaxPool(void); virtual bool LocalMaxPoolGrad(void); //--- virtual bool feedForward(CNeuronBaseOCL *NeuronOCL) override ; //--- virtual bool calcInputGradients(CNeuronBaseOCL *NeuronOCL) override; virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL) override; public: CNeuronPointNet2Local(void) {}; ~CNeuronPointNet2Local(void) {}; //--- virtual bool Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint units_count, uint window_out, float radius, ENUM_OPTIMIZATION optimization_type, uint batch); //--- virtual int Type(void) override const { return defNeuronPointNet2LocalOCL; } //--- 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; };
在上面呈现的结构中,我们可以观察到几个内部神经层对象、和两个变量,我们将在类方法的实现过程中探索其目的。
我们还看到了一组熟悉的可重写方法。此外,还有三种方法对应于之前实现的内核:
- CalcDistance(CNeuronBaseOCL *NeuronOCL);
- LocalMaxPool(void);
- LocalMaxPoolGrad(void).
您或许已猜到,这些方法将内核的执行排入队列。鉴于我们已详细验证过这个算法,故我们不会在本文中进一步深入其中。
还值得注意的是,该类继承自卷积层类 CNeuronConvOCL。这在我们的工作中是一种不常见的举措,主要是由于在局部群中对特征的独立处理。
类的所有内部对象都声明为静态,这允许我们将类构造函数和析构函数留空。新对象实例的初始化在 Init 方法中处理。
bool CNeuronPointNet2Local::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint units_count, uint window_out, float radius, ENUM_OPTIMIZATION optimization_type, uint batch) { if(!CNeuronConvOCL::Init(numOutputs, myIndex, open_cl, 128, 128, window_out, units_count, 1, optimization_type, batch)) return false;
在方法参数中,我们接收定义对象架构的关键常量。这些参数与卷积层中所用的参数非常相似。还有一个额外的参数: radius,它定义元素的感知域半径。
在方法主体中,我们立即调用父类的相应方法,其中已经实现了继承对象的必要数据验证和初始化。重点要注意,传递给父类方法的数值,与从外部程序接收的数值略有不同。这种差异是由于父类对象的特定用法而出现的,我们将在实现 feedForward 方法时重新审视这个主题。
父类方法成功执行后,我们存储一些接收到的常量,而其他常量已在父类操作期间保存。
fRadius = MathMax(0.1f, radius); iUnits = units_count;
接下来,我们转到初始化内部对象。首先,我们创建一个缓冲区,用于记录所分析点云中对象之间的距离。如上所述,它是一个方阵。
cDistance.BufferFree(); if(!cDistance.BufferInit(iUnits * iUnits, 0) || !cDistance.BufferCreate(OpenCL)) return false;
为了提取点特征,我们创建了一个包含 3 个卷积层,和 3 个批量归一化层的模块,类似于 PointNet 算法的特征提取模块。我们不创建源数据投影模块,因为我们假设它存在于顶级类之中。
if(!cFeatureNet[0].Init(0, 0, OpenCL, window, window, 64, iUnits, 1, optimization, iBatch)) return false; if(!cFeatureNetNorm[0].Init(0, 1, OpenCL, 64 * iUnits, iBatch, optimization)) return false; cFeatureNetNorm[0].SetActivationFunction(LReLU); if(!cFeatureNet[1].Init(0, 2, OpenCL, 64, 64, 128, iUnits, 1, optimization, iBatch)) return false; if(!cFeatureNetNorm[1].Init(0, 3, OpenCL, 128 * iUnits, iBatch, optimization)) return false; cFeatureNetNorm[1].SetActivationFunction(LReLU); if(!cFeatureNet[2].Init(0, 4, OpenCL, 128, 128, 256, iUnits, 1, optimization, iBatch)) return false; if(!cFeatureNetNorm[2].Init(0, 5, OpenCL, 256 * iUnits, iBatch, optimization)) cFeatureNetNorm[2].SetActivationFunction(None);
接下来,我们创建一个用于记录局部 MaxPooling 结果的层。
if(!cLocalMaxPool.Init(0, 6, OpenCL, cFeatureNetNorm[2].Neurons(), optimization, iBatch)) return false;
我们添加一个结果 MLP 层。
if(!cFinalMLP.Init(0, 7, OpenCL, 256, 256, 128, iUnits, 1, optimization, iBatch)) return false; cFinalMLP.SetActivationFunction(LReLU);
我们计划使用继承的功能作为第二层。
请注意,不像原版 PointNet,我们在输出时用的是卷积层。这是由于对局部区域描述符的独立处理。
在初始化方法作结束时,我们明确指出我们的类没有激活函数,并将操作的布尔结果返回给调用程序。
SetActivationFunction(None); return true; }
新对象的初始化工作完成后,我们转到在 feedForward 方法中构造前馈通验算法。在该方法参数中,我们接收指向源数据对象的指针。
bool CNeuronPointNet2Local::feedForward(CNeuronBaseOCL *NeuronOCL) { if(!CalcDistance(NeuronOCL)) return false;
如上所述,在该类中,我们不打算将数据投影到规范空间当中。假设如有必要,将在顶层执行此操作。因此,我们立即计算原始数据元素之间的距离。
接下来,我们创建一个循环来计算所分析元素的特征。
CNeuronBaseOCL *temp = NeuronOCL; uint total = cFeatureNet.Size(); for(uint i = 0; i < total; i++) { if(!cFeatureNet[i].FeedForward(temp)) return false; if(!cFeatureNetNorm[i].FeedForward(cFeatureNet[i].AsObject())) return false; temp = cFeatureNetNorm[i].AsObject(); }
针对点的局部区域运行 MaxPooling 操作。
if(!LocalMaxPool()) return false;
在方法操作结束时,我们将独立的两层 MLP 应用于所有局部区域的描述符。
if(!cFinalMLP.FeedForward(cLocalMaxPool.AsObject())) return false; if(!CNeuronConvOCL::feedForward(cFinalMLP.AsObject())) return false; //--- return true; }
作为 MLP 的第一层,我们用到内部层 cFinalMLP。第二层的操作从父类继承的功能执行。
不要忘记监控每个阶段的操作过程。所有操作成功完成后,我们将逻辑结果返回给调用程序。
反向传播算法在方法 calcInputGradients 和 updateInputWeights 中实现。calcInputGradients 方法根据误差梯度对最终结果的影响,将误差梯度分派给所有元素。该算法遵循与 feedForward 方法相同的逻辑,但以相反的顺序执行操作。updateInputWeights 方法更新模型的可训练参数。在此,我们只需调用包含可训练参数的内部对象的相应方法。这两种方法都非常简单。我鼓励您独立探索它们的实现。该类及其所有方法的完整源代码,都可在附件中找到。
2.3汇编 PointNet++ 算法
我们已经完成了大部分工作。现在,我们正接近实现的最后阶段。在该步骤中,我们将各个原件组合成一个统一的 PointNet++ 算法。集成将在 CNeuronPointNet2OCL 类中进行,其结构概述如下。
class CNeuronPointNet2OCL : public CNeuronPointNetOCL { protected: CNeuronPointNetOCL *cTNetG; CNeuronBaseOCL *cTurnedG; //--- CNeuronPointNet2Local caLocalPointNet[2]; //--- virtual bool feedForward(CNeuronBaseOCL *NeuronOCL) override ; //--- virtual bool calcInputGradients(CNeuronBaseOCL *NeuronOCL) override; virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL) override; public: CNeuronPointNet2OCL(void) {}; ~CNeuronPointNet2OCL(void) ; //--- 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); //--- virtual int Type(void) override const { return defNeuronPointNet2OCL; } //--- 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; };
奇怪的是,该类只声明了两个局部数据离散化的静态对象,和两个动态对象,仅当需要将数据投影到规范空间时,才会初始化这两个对象。这种简化是通过继承原版 PointNet 类来实现的,其中大部分功能已经实现。
如前所述,仅在需要时才会初始化动态对象。因此,我们将构造函数留空,但在析构函数中,我们检查指向动态对象指针的有效性,并在必要时将其删除。
CNeuronPointNet2OCL::~CNeuronPointNet2OCL(void) { if(!!cTNetG) delete cTNetG; if(!!cTurnedG) delete cTurnedG; }
类对象的初始化,如常,在 Init 方法中实现。在方法参数中,我们接收定义类架构的关键常量。我们已从父类中完全保留了它们。
bool CNeuronPointNet2OCL::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint units_count, uint output, bool use_tnets, ENUM_OPTIMIZATION optimization_type, uint batch) { if(!CNeuronPointNetOCL::Init(numOutputs, myIndex, open_cl, 64, units_count, output, use_tnets, optimization_type, batch)) return false;
在方法主体中,我们立即调用父类的类似方法。之后,我们检查是否需要创建原始数据的投影对象到规范空间。
//--- Init T-Nets if(use_tnets) { if(!cTNetG) { cTNetG = new CNeuronPointNetOCL(); if(!cTNetG) return false; } if(!cTNetG.Init(0, 0, OpenCL, window, units_count, window * window, false, optimization, iBatch)) return false;
如有必要,我们首先创建必要的对象,然后初始化它们。
if(!cTurnedG) { cTurnedG = new CNeuronBaseOCL(); if(!cTurned1) return false; } if(!cTurnedG.Init(0, 1, OpenCL, window * units_count, optimization, iBatch)) return false; }
如果用户没有指示需要创建投影对象,那么我们检查是否有指向对象的当前指针。如果有这样的指针,我们就会删除不必要的对象。
else { if(!!cTNetG) delete cTNetG; if(!!cTurnedG) delete cTurnedG; }
然后,我们初始化 2 个具有不同接收窗口半径的局部数据采样对象。完成方法执行。
if(!caLocalPointNet[0].Init(0, 0, OpenCL, window, units_count, 64, 0.2f, optimization, iBatch)) return false; if(!caLocalPointNet[1].Init(0, 0, OpenCL, 64, units_count, 64, 0.4f, optimization, iBatch)) return false; //--- return true; }
注意,我们从一个小的接收窗口开始,然后增加它。不过,我们不会将接收窗口增加到完全覆盖,因为这会由从原本的 PointNet 类继承的功能执行。
类对象初始化方法的工作完成后,我们继续在 feedForward 方法中构造前馈通验算法。
bool CNeuronPointNet2OCL::feedForward(CNeuronBaseOCL *NeuronOCL) { //--- LocalNet if(!cTNetG) { if(!caLocalPointNet[0].FeedForward(NeuronOCL)) return false; }
在方法参数中,我们接收指向源数据对象的指针。在方法主体中,我们首先检查是否需要投影到规范空间。此处的过程类似于原本的 PointNet 类中所用的方式。如果不需要数据投影,我们立即将接收到的指针传递给第一个局部离散化层的前馈方法。
否则,我们首先为数据生成投影矩阵。
else { if(!cTurnedG) return false; if(!cTNetG.FeedForward(NeuronOCL)) return false;
之后,我们通过将原始数据乘以投影矩阵来实现原始数据的投影。
int window = (int)MathSqrt(cTNetG.Neurons()); if(IsStopped() || !MatMul(NeuronOCL.getOutput(), cTNetG.getOutput(), cTurnedG.getOutput(), NeuronOCL.Neurons() / window, window, window)) return false;
只有这样,我们才会将获得的值传递给数据采样层的前馈方法。
if(!caLocalPointNet[0].FeedForward(cTurnedG.AsObject())) return false; }
接下来,我们用更大的接收窗口尺寸执行离散化。
if(!caLocalPointNet[1].FeedForward(caLocalPointNet[0].AsObject())) return false;
在最后阶段,我们将丰富的数据传递给父类的前馈方法,其中整个所分析点云的描述符会作为整体进行判定。
if(!CNeuronPointNetOCL::feedForward(caLocalPointNet[1].AsObject())) return false; //--- return true; }
正如您所见,幸亏复杂的继承结构,我们成功地为新类构造了一个简洁的前馈方法。反向传播方法遵循类似的实现,我鼓励您在附件中独立探索。附件中包含本文中用到的所有程序的完整代码。这包括模型、及其与环境互动的完整训练脚本。值得注意的是,这些脚本都是从上一篇文章里原封不动地沿用下来。甚至,我们在很大程度上保留了模型架构。事实上,环境状态编码器中的唯一修改是改动了单层的类型,同时保持所有其它参数不变。
//--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronPointNet2OCL; descr.window = BarDescr; // Variables descr.count = HistoryBars; // Units descr.window_out = LatentCount; // Output Dimension descr.step = int(true); // Use input and feature transformation descr.batch = 1e4; descr.activation = None; descr.optimization = ADAM; if(!encoder.Add(descr)) { delete descr; return false; }
这令评估参与者的新政策训练结果变得更加有趣。
3. 测试
至此,我们已经完成了 PointNet++ 作者所提议方式的实现。现在,是时候依据真实历史数据来评估我们的实现效果了。如前,我们将依据 EURUSD 的 2023 年全年历史数据训练模型。我们使用 H1 时间帧。所有指标参数都设置为默认值。训练后的模型利用 MetaTrader 5 策略测试器进行测试。
如早前所述,我们的新模型与以前的模型仅差一层。甚至,这个新层只是我们之前工作的改进版本。这令两种模型的性能比较变得特别有趣。为了确保比较的公平,我们训练这两个模型时将采用上一个实验中用到的完全相同的数据集。
我始终强调,定期更新训练数据集对于实现最优模型性能至关重要。数据集与参与者的当前政策保持一致可确保更准确地评估其操作,从而优化策略。然而,在这种情况下,我无法抗拒比较两种相似方法,并评估层次化方法有效性的机会。在我们之前的文章中,我们成功地训练了一个能够产生盈利的参与者政策。我们预计新模型至少会具备同样的性能。
训练之后,我们的新模型成功地学会了一个可盈利的政策,在训练和测试数据集上都取得了正回报。新模型的测试结果如下所示。
我必须承认,比较两个模型的结果是相当具有挑战性的。在测试期间,两种模型产生的盈利几乎相同。余额和净值的回撤偏差保持在可忽略不计的误差范围内。然而,新模型执行的交易较少,导致盈利因子略有增加。
话虽如此,两种模型执行的交易数量都很少,因此我们无法对其长期性能得出明确的结论。
结束语
PointNet++ 方法提供了一种有效的方式来分析复杂财务数据中的局部和全局形态,同时参考它们的多维结构。强化的点云处理方式改进了交易策略的预测准确性和稳定性,具有在动态市场中做出更明智和成功决策的潜力。
在本文的实践章节,我们实现了我们自己对于 PointNet++ 方法的愿景。在测试期间,该模型展现出它在测试数据集上产生盈利的能力。不过,重点要注意,所述程序仅出于演示目的,仅概括方法的操作。
参考 文章中所用程序
# | 发行 | 类型 | 说明 |
---|---|---|---|
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/15789


