神经网络在交易中的应用:一种端到端多变量时间序列预测模型(关键组件)
引言
在上一篇文章中,我们深入探讨了 GinAR 框架 — 一种用于处理时间序列的现代架构,它结合了图神经网络的优势以及在异步、不完整和异构数据上进行训练的能力。这种方法的主要思想是将时间序列解释为一种图结构,而不是一个简单的观测序列,其中各个变量或时间点可以通过任意可学习的相互依赖关系进行连接。这种方法对于金融建模问题尤其相关,因为金融数据往往不规则、包含缺失值,并表现出复杂的潜在关系。
GinAR 被提出作为解决此类问题的一种通用工具。其模块化结构包含多个关键组件:
- 插值注意力机制,该机制通过聚合已观测变量的信息来重建缺失值;
- 具有可单独训练权重的图层;
- 自适应归一化函数,使模型对异常值和尺度不稳定性具有鲁棒性。
插值注意力机制在该架构中起着核心作用——它并非传统的自注意力内核,而是一个功能完备的自适应模块,能够同时考虑变量之间的全局依赖关系和观测值的局部上下文。在实践中,这意味着该模型能够利用相邻指标的结构和整体市场情况来预测某个金融指标的值,即使缺少其最近一次观测值。在现实世界中,数据往往延迟到达、间隔不规则或存在显著空白,因此这种方法至关重要。
GinAR 的一个关键特性是它能够在训练过程中动态地重构图结构。与大多数图模型不同,这些模型的结构是预先固定的,而在这里,结构是在训练过程中形成的。这使得模型能够考虑到市场条件、相关性和潜在因素的变化。模型独立地确定哪些变量应该联系在一起,哪些联系应该被削弱,以及哪些变量在当前情境下应被视为关键变量。这创建了一个灵活的架构,能够适应市场运行状态。
作者提供的 GinAR 框架的示意图如下所示。

在上一篇文章的实践部分,我们进行了大量工作,为 OpenCL 程序中的计算奠定了基础。所有关键函数都已实现 — 从局部归约、SoftMax计算,到插值注意力模块中的前向传播 / 反向传播。我们特别关注了本地内存的正确处理、线程同步和数值稳定性,这些在并行计算环境中处理不完整时间序列时尤为重要。
这一准备工作为下一阶段 — 将模型核心整合到主程序中 — 铺平了道路。算法将在这里真正运转起来:数据将从交易环境输入,使用 OpenCL 设备进行处理,通过模型运行,并作为预测和交易决策返回。这一桥接机制是整个 GinAR 架构实现中的核心环节。
插值注意力
今天,我们将面临许多与实现 GinAR 框架的关键组件相关的实际操作工作。所以我们不会在理论上花费太多时间 — 让我们直接进入正题。
第一步是创建一个关键模块,插值注意力,我们将把它实现为一个单独的 CNeuronInterpolationAttention类。此类继承自我们库中神经层的基础对象 CNeuronBaseOCL,并实现了在整个前向传播 / 反向传播周期中高效使用先前准备好的 OpenCL 内核所需的所有逻辑。
class CNeuronInterpolationAttention : public CNeuronBaseOCL { protected: //--- uint iCount; uint iDimension; //--- CParams cW; CParams cA; CParams cGL; //--- CNeuronBaseOCL cH; CNeuronBaseOCL cAdj; CNeuronBaseOCL cAttention; //--- virtual bool InterpolationAttention(CNeuronBaseOCL *NeuronOCL); virtual bool InterpolationAttentionGrad(CNeuronBaseOCL *NeuronOCL); virtual bool feedForward(CNeuronBaseOCL *NeuronOCL) override; virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL) override; virtual bool calcInputGradients(CNeuronBaseOCL *NeuronOCL) override; public: CNeuronInterpolationAttention(void) { activation = None; } ~CNeuronInterpolationAttention(void) {}; //--- virtual bool Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint units_count, uint dimension, ENUM_OPTIMIZATION optimization_type, uint batch); //--- virtual bool Save(const int file_handle) override; virtual bool Load(const int file_handle) override; //--- virtual int Type(void) override const { return defNeuronInterpolationAttention; } virtual void TrainMode(bool flag) override; virtual void SetOpenCL(COpenCLMy *obj); //--- virtual bool WeightsUpdate(CNeuronBaseOCL *source, float tau); virtual void SetActivationFunction(ENUM_ACTIVATION value) override {}; };
该类包含一组内部变量: iCount 和 iDimension 定义源数据张量中的元素数量,而 cW 、 cA 和 cGL 对象表示可训练矩阵的参数。专用对象被分别定义,用于存储隐藏表示、邻接图和注意力权重的中间结果。
核心计算逻辑封装在 InterpolationAttention 和 InterpolationAttentionGrad 方法中,分别负责模型的前向传播和后向传播。这些方法用于处理相应的 OpenCL 内核的准备和执行排队,这些内核是我们上一篇文章中实践操作时创建的。这些方法使用的算法我们已经很熟悉了,因此本文不会详细介绍它们。这些方法的完整代码已附在附件中,供自学使用。
CNeuronInterpolationAttention 类的所有内部对象都被声明为嵌入式成员对象,这简化了实例生命周期的管理。因此,类构造函数和析构函数可以留空 — 它们不需要显式初始化或资源释放。所有必要的配置,包括类自身成员和从父类继承的组件的初始化,都在 Init 方法中集中执行。这提供了一个干净且可预测的初始化结构,有助于避免代码重复,并在模块被重复用于各种模型配置时提高可靠性。
bool CNeuronInterpolationAttention::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint units_count, uint dimension, ENUM_OPTIMIZATION optimization_type, uint batch) { if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, units_count * dimension, optimization_type, batch)) return false;
在方法体中,我们首先调用父类的同名方法 CNeuronBaseOCL 。在这种情况下,会考虑输入信号的空间维度来重新计算层的大小。
接下来,模型的关键参数 — 变量数量和表示维度 — 会被保存下来,以用于计算和内部缓冲区管理。这些参数决定了在正向和反向传播过程中将使用的所有其他向量和矩阵的大小。
iCount = units_count; iDimension = dimension;
以下代码块初始化插值注意力机制中使用的参数和中间张量。首先,创建可训练权重 cW ;它们代表变量之间的个体交互矩阵。这是一个 iDimension × iDimension 的方阵。
int index = 0; if(!cW.Init(0, index, OpenCL, iDimension * iDimension, optimization, iBatch)) return false;
接下来,初始化负责节点间注意力聚合的 cA 参数。
index++; if(!cA.Init(0, index, OpenCL, 2 * iDimension, optimization, iBatch)) return false; index++; if(!cGL.Init(0, index, OpenCL, iDimension * iDimension, optimization, iBatch)) return false;
之后,创建 cGL 对象,该对象表示变量的潜在向量。其维度也为 iDimension × iDimension。
在所有可训练参数初始化完毕后,开始配置负责存储中间结果的辅助对象。cH 缓冲区表示输入信号的线性变换矩阵,该矩阵是通过将输入信号乘以权重得到的。它的维度是 units_count × iDimension 。
index++; if(!cH.Init(0, index, OpenCL, iCount * iDimension, optimization, iBatch)) return false;
接下来是 cAdj 对象,它存储了变量之间调整后的相关值。
index++; if(!cAdj.Init(0, index, OpenCL, iDimension * iDimension, optimization, iBatch)) return false; index++; if(!cAttention.Init(0, index, OpenCL, iDimension * iDimension, optimization, iBatch)) return false; //--- return true; }
最后,cAttention 是通过 SoftMax 归一化后得到的最终注意力值的张量。
每次初始化后都会进行检查,以验证初始化是否成功。如果在任何阶段发生故障,该方法会立即返回 false,从而为防止配置错误提供可靠保护。
如果所有对象都已成功配置并放置在 OpenCL 设备的内存中,则该方法完成,并返回 true。这意味着该层已完全准备好运行,可作为模型的一部分使用。
对象初始化完成后,我们继续设置前向传播过程,该过程在 feedForward 方法中实现。其任务是正确准备所有输入数据,并使用之前开发的 OpenCL 内核运行主要计算。该算法相当紧凑,但包含几个关键阶段,每个阶段在该层整体运行中都扮演着重要角色。
bool CNeuronInterpolationAttention::feedForward(CNeuronBaseOCL *NeuronOCL) { if(bTrain) { if(!cW.FeedForward()) return false; if(!cA.FeedForward()) return false; if(!cGL.FeedForward()) return false; } if(!InterpolationAttention(NeuronOCL)) return false; //--- return true; }
首先,该方法检查是否设置了 bTrain 标志,该标志表示该层处于训练模式。这一点至关重要,因为在这种情况下,模型的可训练参数必须首先通过它们各自的 FeedForward 方法进行处理。这一调用对于更新参数值并确保它们参与当前计算周期是必要的。所有这三个调用都包含一个成功检查,一旦出现任何失败,方法会立即停止执行并返回 false。这可以防止未初始化或无效的参数被带入后续计算的情况发生。
所有准备步骤完成后,将调用 InterpolationAttention 主方法。该函数启动 OpenCL 内核,该内核负责计算注意力、聚合值并生成层的输出表示。
最后,该方法将布尔结果返回给调用者。
calcInputGradients 方法以极其简洁的方式实现,因为所有的反向传播逻辑都已经移至相应的 OpenCL 内核中。在此,我们只需调用包装器函数 InterpolationAttentionGrad,该函数负责启动内核并传递必要的缓冲区。然后,我们将操作的布尔结果返回给调用者。
bool CNeuronInterpolationAttention::calcInputGradients(CNeuronBaseOCL *NeuronOCL) { if(!InterpolationAttentionGrad(NeuronOCL)) return false; //--- return true; }
此实现完全保留了神经层的整体操作结构,使得该模块能够与其他类型的层一起使用,而无需对训练逻辑进行任何更改。这凸显了系统的架构灵活性,并尽可能地简化了集成新解决方案的流程,同时确保了其安全性。
模块中可训练参数的更新尽可能简洁且逻辑清晰。计算中使用的所有权重都是该类的内部对象(cW、cA、cGL),每个对象都封装了自己的更新算法。updateInputWeights 方法只需调用它们对应的函数,从而确保了模块化和简洁的架构。
bool CNeuronInterpolationAttention::updateInputWeights(CNeuronBaseOCL *NeuronOCL) { if(!cW.UpdateInputWeights()) return false; if(!cA.UpdateInputWeights()) return false; if(!cGL.UpdateInputWeights() || !Normilize(cGL.getWeightsParams(), 2 * iDimension)) return false; //--- return true; }
特别关注 cGL 张量,该张量在更新后,还会使用 Normilize 函数进行额外归一化。这是必要的,因为 cGL 包含变量之间基于相关性的链接的权重,这些系数的稳定性直接影响整个注意力模块的稳定性。归一化有助于平滑任何异常值,并保持权重的可解释性。因此,该方法无需任何额外的干预或配置即可完全完成训练周期。
附件中包含了 CNeuronInterpolationAttention 类的完整源代码及其所有方法的实现。为了保持文本的生动性,我们刻意避免在本文中过多地赘述技术细节;然而,我们建议查阅源代码,以便全面理解该模块的逻辑及其如何融入整个 GinAR 框架。
AGCN
开发 GinAR 框架的下一个逻辑步骤是实施自适应图卷积(AGCN)机制。这是该框架的关键组成部分,使得能够考虑图中以节点表示的时间序列之间的复杂关系。与传统方法不同,在传统方法中,图结构是预先指定的,并且在整个训练过程中保持固定,而自适应卷积允许模型根据数据的当前状态独立地形成和修改连接的拓扑结构。在处理动态金融时间序列时,这种灵活性至关重要,因为其结构会受到持续波动、隐藏的相互关系和外部干扰的影响。
为了实现这一想法,我们开发了一个单独的模块,即 CNeuronAGCN 类。该类继承自基础全连接层 CNeuronBaseOCL ,并封装了使用 OpenCL 加速执行自适应卷积所需的所有逻辑。其内部结构设计旨在确保在处理的每个阶段,包括特征提取、连接矩阵形成、归一化、聚合以及图权重的训练,都能实现最高的计算效率和准确性。
该模块的一个显著特点是,在信息处理过程中,它不依赖于固定的邻接矩阵,而是实时计算。为此,输入数据会经过一系列转换,从而为每个节点生成一个潜在表示。这些表示被用来计算节点之间的相似性,并在此基础上构建自适应连接矩阵。这样,模型基本上会查看每个时间序列,并自行决定将其与哪些其他序列连接,以及连接的强度。其结果是一个灵活的注意力机制,其中每个节点都能够根据上下文调整其行为。
新类的结构如下所示。
class CNeuronAGCN : public CNeuronBaseOCL { protected: CParams cEa; CNeuronSwiGLUOCL cWx; CNeuronSwiGLUOCL cWe; CNeuronBaseOCL cWconcat_ex; CNeuronConvOCL cEn; CNeuronTransposeOCL cEnT; CNeuronBaseOCL cEnEnT; CNeuronSoftMaxOCL cAadapt; CNeuronBaseOCL cAadaptX; CNeuronBaseOCL cApreX; CNeuronSwiGLUOCL cWadapt; CNeuronSwiGLUOCL cWpre; //--- virtual bool feedForward(CNeuronBaseOCL *NeuronOCL) override { return false; } virtual bool feedForward(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput) override; virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL) override { return false; } virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL, CBufferFloat *second) override; virtual bool calcInputGradients(CNeuronBaseOCL *NeuronOCL) override { return false; } virtual bool calcInputGradients(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput, CBufferFloat *SecondGradient, ENUM_ACTIVATION SecondActivation = None) override; public: CNeuronAGCN(void) {activation = None;} ~CNeuronAGCN(void) {}; //--- virtual bool Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint units_count, uint dimension, ENUM_OPTIMIZATION optimization_type, uint batch); //--- virtual bool Save(const int file_handle) override; virtual bool Load(const int file_handle) override; //--- virtual int Type(void) override const { return defNeuronAGCN; } virtual void TrainMode(bool flag) override; virtual void SetOpenCL(COpenCLMy *obj); //--- virtual bool WeightsUpdate(CNeuronBaseOCL *source, float tau); //--- virtual uint GetWindow(void) const { return cWx.GetWindow(); } virtual uint GetUnits(void) const { return cWx.GetUnits(); } virtual void SetActivationFunction(ENUM_ACTIVATION value) override {}; };
整个计算链被组织在一个单独的对象中,但它包含了许多辅助层和操作。逻辑被分解为多个阶段,我们将在实现类方法时逐一探讨。输出特征可视为在自适应图上进行协调滤波后得到的结果,同时考虑了内容信息以及时间序列之间的拓扑关系。
与上一层一样,CNeuronAGCN 类的所有内部组件都以内嵌成员对象的形式声明。这种方法使我们能够将类构造函数和析构函数留空,将所有初始化逻辑集中在一个方法中:Init。这确保了实现的简洁性,也使得管理层内对象的生命周期变得更加容易。
bool CNeuronAGCN::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint units_count, uint dimension, ENUM_OPTIMIZATION optimization_type, uint batch) { if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, units_count * dimension, optimization_type, batch)) return false;
初始化过程首先调用父类中同名的方法,在该方法中执行接口和通用层参数的初始配置。之后,逐步配置自适应图卷积的所有关键组件。
在第一步中,会创建一个 cEa 对象,该对象将用于编码有关数据结构的附加信息。
//--- int index = 0; if(!cEa.Init(0, index, OpenCL, Neurons(), optimization, iBatch)) return false; cEa.SetActivationFunction(None);
接下来,初始化两个重要层 — cWx 和 cWe;它们负责从输入数据和之前学习的时间序列结构中提取特征。这两个层均使用 SwiGLU 机制实现,并并行训练。
index++; if(!cWx.Init(0, index, OpenCL, dimension, dimension, dimension, units_count, 1, optimization, iBatch)) return false; index++; if(!cWe.Init(0, index, OpenCL, dimension, dimension, dimension, units_count, 1, optimization, iBatch)) return false;
接下来是 cWconcat_ex 缓冲区,该缓冲区用于组合潜在空间中的表示。
index++; if(!cWconcat_ex.Init(0, index, OpenCL, 2 * Neurons(), optimization, iBatch)) return false; cWconcat_ex.SetActivationFunction(None);
初始化过程中的下一个元素是 cEn 层,该层对组合特征进行线性变换。它由 cEnT 转置模块辅助,该模块为形成初步注意力矩阵准备数据。
index++; if(!cEn.Init(0, index, OpenCL, 2 * dimension, 2 * dimension, dimension, units_count, 1, optimization, iBatch)) return false; cEn.SetActivationFunction(None); index++; if(!cEnT.Init(0, index, OpenCL, units_count, dimension, optimization, iBatch)) return false; index++; if(!cEnEnT.Init(0, index, OpenCL, units_count * units_count, optimization, iBatch)) return false; cEnEnT.SetActivationFunction(GELU);
在转置步骤之后,准备好的数据被传递给矩阵乘法运算,在该运算中,原始表示与其转置版本之间执行点积运算。结果是一个对称关系矩阵,该矩阵存储在 cEnEnT 对象中。正是在这个模块中,结果值被传递给 GELU 函数;该函数允许在不完全消除负相关性的情况下,温和地抑制负相关性,同时减少它们对最终表示的贡献。
这种方法有助于识别图中节点之间最重要的模式和潜在关系,这在构建自适应注意力矩阵时至关重要。最终,得益于这一操作,模型能够捕捉到图中更深层次的结构模式,并正确权衡节点间的交互作用。
接下来是一个关键步骤 — 对获取的值进行归一化。为此,我们使用了 SoftMax 函数,该函数在 cAadapt 对象内部应用。此操作将原始权重系数转换为概率分布,其中图节点之间的每条边都被赋予了特定的显著度。因此,弱且嘈杂的依赖关系被抑制,而强且信息丰富的依赖关系则被放大。结果是一个完全成形的自适应注意力矩阵,它反映了输入信号各组件之间的个体交互模式。
index++; if(!cAadapt.Init(0, index, OpenCL, units_count * units_count, optimization, iBatch)) return false; cAadapt.SetHeads(units_count);
下一个逻辑操作是将自适应权重应用于输入数据。这一阶段是通过用相应的注意力系数对输入特征进行加权来实现的。结果存储在 cAadaptX 缓冲区中,该缓冲区表示输入信号的更新且上下文丰富的表示。从本质上讲,这是对原始输入进行重新加权投影,其中考虑了图中各个节点之间已识别的关系。
index++; if(!cAadaptX.Init(0, index, OpenCL, Neurons(), optimization, iBatch)) return false; cAadaptX.SetActivationFunction(None); index++; if(!cApreX.Init(0, index, OpenCL, Neurons(), optimization, iBatch)) return false; cApreX.SetActivationFunction(None);
一个额外的处理步骤涉及根据预定义的图结构调整输入数据。这一步骤旨在增强在训练模型之外推导出的节点之间已知的、先验关系的影响 — 例如,基于基本关系、拓扑结构或专家规则。这里的机制与前一个类似:权重被应用于原始张量,但不是来自自适应注意力矩阵;相反,它们来自外部系数矩阵。结果存储在 cApreX 缓冲区中。因此,模型同时接收两个独立的信息通道 — 一个基于自动学习和识别的模式,另一个基于结构化的先验信息。将它们结合起来,可以在模型的灵活性和鲁棒性之间实现更好的平衡。
最后,在初始化过程的最后部分,创建了两个卷积层:cWadapt 和 cWpre。这两个卷积层都采用 SwiGLU 架构,作为过滤器,使特征适应生成的连接矩阵的结构。
index++; if(!cWadapt.Init(0, index, OpenCL, dimension, dimension, dimension, units_count, 1, optimization, iBatch)) return false; cWadapt.SetActivationFunction(None); index++; if(!cWpre.Init(0, index, OpenCL, dimension, dimension, dimension, units_count, 1, optimization, iBatch)) return false; cWpre.SetActivationFunction(None); SetActivationFunction(None); //--- return true; }
一旦所有这些步骤都完成,我们会明确禁用该层的激活函数,因为在内部块中已经应用了非线性函数,无需在输出端重复使用。
因此,整个 Init 方法是一系列精心组织的步骤,在此期间,自适应卷积的所有元素都被创建和配置。每个组件负责计算的一个特定部分,并融入层的整体架构中,为模型的进一步运行奠定基础。
我们工作的下一阶段是 feedForward 方法,该方法实现了自适应图卷积的完整前向传播周期,同时考虑了可训练连接和外部指定的结构连接。该算法相当复杂,因此我们将逐步对其进行分析,重点关注每个模块的语义逻辑。
bool CNeuronAGCN::feedForward(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput) { if(!SecondInput || SecondInput.Total() < cAadapt.Neurons()) return false;
在算法开始执行之前,必须进行检查以验证输入数据的有效性:如果预定义的关联系数矩阵 SecondInput 缺失,或者其大小不足以执行矩阵运算,则立即终止执行。
接下来,如果模型处于训练模式,则会为负责节点自适应上下文的分支启动参数准备循环。首先,通过 cEa 对象执行前向传播,该对象包含用于计算边嵌入的可训练参数。结果传递给 cWe,在此形成外部交互权重矩阵,其中的权重反映了当前训练步骤中节点之间连接的上下文。
if(bTrain) { if(!cEa.FeedForward()) return false; if(!cWe.FeedForward(cEa.AsObject())) return false; }
同时,主分支(即处理主要特征序列的分支)被激活。在该分支中,调用 cWx 前向传播方法,形成节点当前状态的表示。
if(!cWx.FeedForward(NeuronOCL)) return false; if(!Concat(cWx.getOutput(), cWe.getOutput(), cWconcat_ex.getOutput(), cWx.GetWindowOut(), cWe.GetWindowOut(), cWx.GetUnits())) return false;
之后,将两个输出向量(cWx 和 cWe)在各个序列元素上进行拼接,这使得模型能够在后续操作中考虑结构和上下文信息。得到的输出用于 cEn,该操作执行线性卷积,以降低维度并增强有意义的特征。
if(!cEn.FeedForward(cWconcat_ex.AsObject())) return false; if(!cEnT.FeedForward(cEn.AsObject())) return false; if(!MatMul(cEn.getOutput(), cEnT.getOutput(), cEnEnT.getOutput(), cEnT.GetCount(), cEnT.GetWindow(), cEnT.GetCount())) return false; if(cEnEnT.Activation() != None) if(!Activation(cEnEnT.getOutput(), cEnEnT.getOutput(), cEnEnT.Activation())) return false;
接下来,进行一项关键操作:将得到的矩阵转置,并将其与原始矩阵相乘,从而在 cEnEnT 对象中构建一个对称的相关矩阵。如果对该对象应用激活函数(在我们的例子中为 GELU),则会调用该函数,从而减轻噪声和负相关的影响。
然后将得到的矩阵传递给 cAadapt 对象,在该对象中, SoftMax 机制在多头注意力结构内得到应用。该矩阵按行对权重进行归一化,并返回一个适应当前数据结构的注意力矩阵。为了加强其对角线连接,调用 IdentSum 函数;该函数将一个单位矩阵添加到结果中,从而确保图对稀疏输入的鲁棒性。
if(!cAadapt.FeedForward(cEnEnT.AsObject())) return false; if(!IdentSum(cAadapt.getOutput(), cAadapt.getOutput(), cAadapt.Heads())) return false;
然后,该模型计算两个并行的矩阵乘法流。第一个流处理外部数据 SecondInput,将其与神经层的当前输出相乘;结果存储在 cApreX 缓冲区中。第二个流以类似的方式处理 cAadapt 注意力矩阵和当前输出,并将结果存储在 cAadaptX 中。
if(!MatMul(SecondInput, NeuronOCL.getOutput(), cApreX.getOutput(), cWpre.GetUnits(), cWpre.GetUnits(), cWpre.GetWindow())) return false; if(!MatMul(cAadapt.getOutput(), NeuronOCL.getOutput(), cAadaptX.getOutput(), cWadapt.GetUnits(), cWadapt.GetUnits(), cWadapt.GetWindow())) return false;
最后一步是将两个流都通过可训练的卷积块 cWpre 和 cWadapt。这使得模型能够针对局部特征对结果数据进行细化。
if(!cWpre.FeedForward(cApreX.AsObject())) return false; if(!cWadapt.FeedForward(cAadaptX.AsObject())) return false; if(!SumAndNormilize(cWadapt.getOutput(), cWpre.getOutput(), Output, cWadapt.GetWindowOut(), true)) return false; //--- return true; }
之后,调用 SumAndNormalize 函数;该函数将两个流合并,并将结果调整为层输出所需的形式。
因此,该方法同时实现了多种强大的机制:自适应注意力、特征提取、结构整合和卷积变换。所有这些都将该模块转变为一个功能齐全的图形处理块,能够同时考虑节点之间的内部依赖关系和外部结构约束。
一旦自适应图卷积(AGCN)中的前向传递完成,最关键的阶段之一就开始了:将误差梯度从层输出传播到其可训练组件。作为此过程的一部分,执行反向传播,从而可以精确确定每个对象对最终结果的贡献,并调整参数以最小化误差。整个算法在 calcInputGradients 方法中实现。
bool CNeuronAGCN::calcInputGradients(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput, CBufferFloat *SecondGradient, ENUM_ACTIVATION SecondActivation = -1) { if(!NeuronOCL || !SecondInput || !SecondGradient || SecondInput.Total() < cAadapt.Neurons() || SecondGradient.Total() < cAadapt.Neurons()) return false;
第一步,对输入数据进行标准验证检查:如果接收到的指针未初始化,或者输入缓冲区不足以执行矩阵运算,则停止执行该方法。接下来,开始计算实际误差分布。
AGCN 块中的反向传播从最末端开始 — 具体来说,是从层输出开始,此时最终结果已经形成。在我们的例子中,这是两个关键组件输出之和:cWadapt 和 cWpre。提醒一下,正是这些组件有助于构建最终表示,该表示既概括了图的注意力机制,也概括了图的静态结构。
由于模块输出是两个张量的简单和,因此从后续层接收到的误差梯度必须平等地传递给两个模块 — 没有任何额外的加权系数。然而,在开始通过每个模块的内部连接计算梯度之前,我们必须调整此梯度以考虑前向传播中使用的激活函数。为此,我们调用 DeActivation 方法 — 本质上,它将获得的梯度乘以应用于 cWadapt 和 cWpre 输出的激活函数的导数。这是关键的一步;没有它,通过非线性变换的反向传播将是不正确的。
if(!DeActivation(cWadapt.getOutput(), cWadapt.getGradient(), Gradient, cWadapt.Activation())) return false; if(!DeActivation(cWpre.getOutput(), cWpre.getGradient(), Gradient, cWpre.Activation())) return false;
之后,误差被分配到它们的输入端:分别为 cAadaptX 和 cApreX 对象调用 CalcHiddenGradients 方法,通过这些方法,分别传递由节点之间自适应连接矩阵和预定义连接矩阵形成的流。
if(!cApreX.CalcHiddenGradients(cWpre.AsObject())) return false; if(!cAadaptX.CalcHiddenGradients(cWadapt.AsObject())) return false;
接下来,使用 MatMulGrad,通过乘法运算进行反向传播:计算每个分量对总误差的贡献。如果使用了激活函数,则使用 DeActivation 进行其逆变换,从而使真实误差恢复到其归一化前的状态。
if(!MatMulGrad(SecondInput, SecondGradient, NeuronOCL.getOutput(), NeuronOCL.getGradient(), cApreX.getGradient(), cWpre.GetUnits(), cWpre.GetUnits(), cWpre.GetWindow())) return false; if(SecondActivation != None) if(!DeActivation(SecondInput, SecondGradient, SecondGradient, SecondActivation)) return false; //--- if(!MatMulGrad(cAadapt.getOutput(), cAadapt.getGradient(), NeuronOCL.getOutput(), PrevOutput, cAadaptX.getGradient(), cWadapt.GetUnits(), cWadapt.GetUnits(), cWadapt.GetWindow())) return false; if(!SumAndNormilize(NeuronOCL.getGradient(), PrevOutput, PrevOutput, cWx.GetWindow(), false)) return false; if(NeuronOCL.Activation() != None) if(!DeActivation(NeuronOCL.getOutput(), PrevOutput, PrevOutput, NeuronOCL.Activation())) return false;
请注意,在前向传递过程中执行的两次矩阵乘法运算中,都使用了来自主信息流的输入数据。因此,我们需要沿两个分支累加误差梯度。我们使用临时缓冲区 PrevOutput 来累加这些值。如果 NeuronOCL 使用了激活函数,则应用其导数来正确重新计算梯度,同时考虑非线性因素。
下一个关键步骤是通过 cEnEnT 相关矩阵进行反向传播,该矩阵是在前向传播过程中提前获得的。首先,计算隐藏梯度;然后,如果使用了 GELU 函数,则在输出端应用去激活操作。
if(!cEnEnT.CalcHiddenGradients(cAadapt.AsObject())) return false; if(cEnEnT.Activation() != None) if(!DeActivation(cEnEnT.getOutput(), cEnEnT.getGradient(), cEnEnT.getGradient(), cEnEnT.Activation())) return false;
接下来,调用 MatMulGrad,通过将特征与其转置副本相乘的操作来实现反向梯度传播。
if(!MatMulGrad(cEn.getOutput(), cEn.getPrevOutput(), cEnT.getOutput(), cEnT.getGradient(), cEnEnT.getGradient(), cEnT.GetCount(), cEnT.GetWindow(), cEnT.GetCount())) return false;
此外,转置误差被传递到 cEn 层,在那里它们与之前获得的值相加,并且如果使用了激活函数,则执行去激活操作。
if(!cEn.CalcHiddenGradients(cEnT.AsObject())) return false; if(!SumAndNormilize(cEn.getGradient(), cEn.getPrevOutput(), cEn.getGradient(), cEnT.GetWindow(), false)) return false; if(cEn.Activation() != None) if(!DeActivation(cEn.getOutput(), cEn.getGradient(), cEn.getGradient(), cEn.Activation())) return false;
紧接着,梯度被输入到 cWconcat_ex 中,在那里开始执行分离特征的反向过程(回想一下,这些特征在前向传递过程中是组合在一起的)。
if(!cWconcat_ex.CalcHiddenGradients(cEn.AsObject())) return false; if(!DeConcat(cWx.getGradient(), cWe.getGradient(), cWconcat_ex.getGradient(), cWx.GetWindowOut(), cWe.GetWindowOut(), cWx.GetUnits())) return false; if(cWx.Activation() != None) if(!DeActivation(cWx.getOutput(), cWx.getGradient(), cWx.getGradient(), cWx.Activation())) return false; if(cWe.Activation() != None) if(!DeActivation(cWe.getOutput(), cWe.getGradient(), cWe.getGradient(), cWe.Activation())) return false;
DeConcat 函数正确地将梯度拆分为两部分 — 一部分用于 cWx,另一部分用于 cWe。在这些模块中,DeActivation 分别被调用,以恢复干净的梯度。接下来,反向传播通过网络进行:首先通过 `cWx` 传播到输入数据层,然后通过 cWe 传播到 cEa 。
if(!NeuronOCL.CalcHiddenGradients(cWx.AsObject())) return false; if(!SumAndNormilize(NeuronOCL.getGradient(), PrevOutput, NeuronOCL.getGradient(), cWx.GetWindow(), false)) return false; if(!cEa.CalcHiddenGradients(cWe.AsObject())) return false; //--- return true; }
请注意,我们已经将误差梯度传递到了主分支的输入数据层,因此我们将得到的值与之前累积的值相加。
尤其值得注意的是,该方法严格按照步骤顺序执行。这是必要的,因为误差必须严格按照前向传播的相反顺序向后传播。每一步都仔细地补偿了卷积、归一化、激活和转置的影响。只有在完全计算出梯度之后,模型才准备更新其权重,从而在训练的每个阶段都保持准确性。
更新参数的方法以一种极其直接、优雅且可靠的方式实现。这里没有复杂的条件或额外的计算 — 一切都归结为将控制权小心地传递给直接包含可训练权重的内部对象。每个对象都根据累积的梯度和所选的优化方案实现自己的更新机制。
bool CNeuronAGCN::updateInputWeights(CNeuronBaseOCL *NeuronOCL, CBufferFloat *second) { if(!cEa.UpdateInputWeights()) return false; if(!cWe.UpdateInputWeights(cEa.AsObject())) return false; if(!cWx.UpdateInputWeights(NeuronOCL)) return false; if(!cEn.UpdateInputWeights(cWconcat_ex.AsObject())) return false; if(!cWpre.UpdateInputWeights(cApreX.AsObject())) return false; if(!cWadapt.UpdateInputWeights(cAadaptX.AsObject())) return false; //--- return true; }
每个方法都使用在前向传递过程中获得的适当输入对象来调用,这保证了在图拓扑结构下的计算正确性。
这种方法使得代码结构不仅可读,而且易于扩展。
为了方便读者并确保技术上的完全透明,本类的全部源代码(包括所有方法的定义)已在附件中提供。
GinAR 单元
我们现在进入最后阶段 — 创建一个完整的 GinAR 计算单元,它将之前讨论的所有组件整合到一个单一、有凝聚力的结构中。正是在 CNeuronGinARCell 类中,插值注意力机制、自适应图卷积和记忆控制元素汇聚在一起,形成了一个灵活而又严格结构化的架构。
class CNeuronGinARCell : public CNeuronBaseOCL { protected: CNeuronInterpolationAttention cX_IA; CNeuronAGCN cX_AGCN; CNeuronAGCN cForgetGate; CNeuronAGCN cResetGate; CNeuronBaseOCL cContext; CBufferFloat bTemp; //--- virtual bool feedForward(CNeuronBaseOCL *NeuronOCL) override { return false; } virtual bool feedForward(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput) override; virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL) override { return false; } virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL, CBufferFloat *second) override; virtual bool calcInputGradients(CNeuronBaseOCL *NeuronOCL) override { return false; } virtual bool calcInputGradients(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput, CBufferFloat *SecondGradient, ENUM_ACTIVATION SecondActivation = None) override; public: CNeuronGinARCell(void) { activation = None;} ~CNeuronGinARCell(void) {}; //--- virtual bool Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint units_count, uint dimension, ENUM_OPTIMIZATION optimization_type, uint batch); //--- virtual bool Save(const int file_handle) override; virtual bool Load(const int file_handle) override; //--- virtual int Type(void) override const { return defNeuronGinARCell; } virtual void TrainMode(bool flag) override; virtual void SetOpenCL(COpenCLMy *obj); //--- virtual bool WeightsUpdate(CNeuronBaseOCL *source, float tau); virtual void SetActivationFunction(ENUM_ACTIVATION value) override {}; };
从上面所示的新对象的结构中可以清楚地看出, CNeuronGinARCell 继承了 CNeuronBaseOCL 的基本接口。这样可以确保与框架的其他层和模块兼容。然而,与简单的模块不同,这个模块结合了多个功能组件:
- cX_IA — 插值注意力模块,负责平滑和调整输入时间序列;
- cX_AGCN — 主要的自适应图卷积模块,它在考虑输入特征之间的关系的同时,形成输入特征的通用表示;
- cForgetGate 和 cResetGate 是两个额外的基于图的子系统,它们实现了类似于 GRU 单元中的遗忘和重置机制;
- cContext — 一个内部缓冲区,用于存储隐藏内存的累积状态;
- bTemp — 用于中间计算的临时缓冲区。
该类实现了神经网络层的标准执行周期。Init 方法逐步初始化 CNeuronGinARCell 的所有组件。此处指定了模型的主要参数:输出数量、特征空间维度、优化类型以及批量大小。
bool CNeuronGinARCell::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint units_count, uint dimension, ENUM_OPTIMIZATION optimization_type, uint batch) { if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, units_count * dimension, optimization_type, batch)) return false;
接下来,逐一初始化单元格的内部模块。首先,初始化负责插值注意力的 cX_IA 。
int index = 0; if(!cX_IA.Init(0, index, OpenCL, units_count, dimension, optimization, iBatch)) return false;
接下来,初始化主图卷积模块 cX_AGCN ,然后是控制门cForgetGate 和 cResetGate 。每个对象都有其唯一的索引。
index++; if(!cX_AGCN.Init(0, index, OpenCL, units_count, dimension, optimization, iBatch)) return false; index++; if(!cForgetGate.Init(0, index, OpenCL, units_count, dimension, optimization, iBatch)) return false; index++; if(!cResetGate.Init(0, index, OpenCL, units_count, dimension, optimization, iBatch)) return false;
作为单独的步骤,初始化 cContext 模块,用作内部状态的累加器。其输出被明确填充为零,以避免启动时的随机噪声,并且禁用激活函数,使其可作为纯存储缓冲区使用。
index++; if(!cContext.Init(0, index, OpenCL, units_count * dimension, optimization, iBatch)) return false; if(!cContext.getPrevOutput().Fill(0)) return false; cContext.SetActivationFunction(None); bTemp.BufferFree(); if(!bTemp.BufferInit(units_count * units_count, 0) || !bTemp.BufferCreate(OpenCL)) return false; //--- return true; }
最后,创建一个临时缓冲区 bTemp 来执行中间操作。缓冲区大小选择为二次方: units_count * units_count 。在这种情况下,如果缓冲区已经创建,则会先将其释放,以确保操作正确并节约资源。
完成所有操作后,该方法会向调用者返回一个布尔结果。
feedForward 方法中内置的前向传递算法实现了 CNeuronGinARCell 的关键操作逻辑,结合了注意力机制、图卷积以及通过遗忘门和重置门进行的内存管理。
bool CNeuronGinARCell::feedForward(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput) { if(!cX_IA.FeedForward(NeuronOCL)) return false;
在第一阶段,激活 cX_IA 模块,对来自 NeuronOCL 的源数据执行插值变换。由此得到的表示被用作三个并行模块的基础 — 主图卷积模块 cX_AGCN ,以及负责生成控制掩码的 cForgetGate 和 cResetGate 模块。
if(!cX_AGCN.FeedForward(cX_IA.AsObject(), SecondInput)) return false; if(!cForgetGate.FeedForward(cX_IA.AsObject(), SecondInput)) return false; if(!cResetGate.FeedForward(cX_IA.AsObject(), SecondInput)) return false; if(!Activation(cForgetGate.getOutput(), cForgetGate.getOutput(), GELU)) return false; if(!Activation(cResetGate.getOutput(), cResetGate.getOutput(), GELU)) return false;
这三个模块中的每一个都接受第二个输入 SecondInput ,其中包含图或结构信息 — 邻接矩阵或有关相邻节点的数据。经过各层之后, cForgetGate 和 cResetGate 门的输出通过 GELU 激活函数,从而可以创建更平滑、更灵活的掩码,减少突变和负值的影响。
接下来,更新内部上下文。值得注意的是,在前向传递过程中,反向传递操作所需的上下文值会被立即覆盖。为了防止这种情况发生,我们使用缓冲区轮换:我们调用 SwapOutputs 方法,该方法交换缓冲区指针 Output 和 PrevOutput 。
//--- Context if(!cContext.SwapOutputs()) return false; if(!GateElementMult(cContext.getPrevOutput(), cX_AGCN.getOutput(), cForgetGate.getOutput(), cContext.getOutput())) return false;
接下来,通过将 cContext 的先前状态与 cX_AGCN 的结果逐元素相乘,并以遗忘掩码 cForgetGate 为权重,形成一个新的上下文向量。这样就可以灵活控制哪些内存元素应该保留,哪些应该清零。
新的上下文向量经过一个经过调整以处理负值的 ELU 激活函数,从而提高了梯度稳定性。
//--- Output if(!Activation(cContext.getOutput(), cContext.getPrevOutput(), ELU)) return false; if(!GateElementMult(cContext.getPrevOutput(), cX_IA.getOutput(), cResetGate.getOutput(), Output)) return false; //--- return true; }
最后,Output 缓冲区中的块的最终输出是通过将更新后的上下文、结果张量 cX_IA 和掩码 cResetGate 逐元素相乘而形成的。此步骤结合了长期记忆、当前输入状态和复位掩码,负责将相关特征传递给输出。
如果在执行操作的任何阶段发生错误或故障,该方法将返回 false 。如果所有操作都成功完成,则返回 true ,确认数据已正确传递到整个单元结构中。
反向传播算法并不像乍看起来那么简单。其主要复杂性在于需要仔细地对来自多个来源的梯度进行求和和归一化,因为来自两个分支的输入数据同时用于多个地方:在主流( cX_AGCN )中,以及通过控制门 cForgetGate 和 cResetGate 的辅助路径中。因此,每个消费者都会对最终梯度产生自己的贡献,所有这些贡献都必须正确组合起来。
bool CNeuronGinARCell::calcInputGradients(CNeuronBaseOCL *NeuronOCL, CBufferFloat *SecondInput, CBufferFloat *SecondGradient, ENUM_ACTIVATION SecondActivation = -1) { if(!NeuronOCL || !SecondInput || !SecondGradient) return false; //--- Output if(!GateElementMultGrad(cContext.getPrevOutput(), cContext.getGradient(), cX_IA.getOutput(), cX_IA.getPrevOutput(), cResetGate.getOutput(), cResetGate.getGradient(), Gradient, ELU, cX_IA.Activation(), GELU)) return false;
首先,将单元最终输出的梯度分配到上下文 cContext 、插值注意力结果 cX_IA 和重置门 cResetGate 中。在此过程中,考虑了所有关于相应激活函数的导数。
接下来,重建内部记忆状态 cContext 的梯度。由于它是通过将主卷积模块 cX_AGCN 的输出与 cForgetGate 掩码相乘而形成的,因此使用了类似的反向传播操作。这里还涉及其他激活函数和其他梯度,并将结果累积到 cContext 对象中。
//--- Context if(!GateElementMultGrad(cContext.getOutput(), cContext.getPrevOutput(), cX_AGCN.getOutput(), cX_AGCN.getGradient(), cForgetGate.getOutput(), cForgetGate.getGradient(), cContext.getGradient(), None, cX_AGCN.Activation(), GELU)) return false;
接下来是最关键的步骤之一 — 将梯度传递回 cX_IA 插值注意力模块。它作为三个分支的共同前身,因此需要对 CalcHiddenGradients 进行三次独立的调用,每个路径一次:主分支 ( cX_AGCN )、遗忘门 ( cForgetGate ) 和重置门 ( cResetGate )。
//--- Gradient to Interpolation Attention if(!cX_IA.CalcHiddenGradients(cX_AGCN.AsObject(), SecondInput, SecondGradient, SecondActivation) || !SumAndNormilize(cX_IA.getGradient(), cX_IA.getPrevOutput(), cX_IA.getPrevOutput(), cForgetGate.GetWindow(), false, 0, 0, 0, 1)) return false; if(!cX_IA.CalcHiddenGradients(cForgetGate.AsObject(), SecondInput, GetPointer(bTemp), SecondActivation) || !SumAndNormilize(cX_IA.getGradient(), cX_IA.getPrevOutput(), cX_IA.getPrevOutput(), cForgetGate.GetWindow(), false, 0, 0, 0, 1) || !SumAndNormilize(SecondGradient, GetPointer(bTemp), SecondGradient, cForgetGate.GetUnits(), false, 0, 0, 0, 1)) return false; if(!cX_IA.CalcHiddenGradients(cResetGate.AsObject(), SecondInput, GetPointer(bTemp), SecondActivation) || !SumAndNormilize(cX_IA.getGradient(), cX_IA.getPrevOutput(), cX_IA.getPrevOutput(), cForgetGate.GetWindow(), false, 0, 0, 0, 1) || !SumAndNormilize(SecondGradient, GetPointer(bTemp), SecondGradient, cForgetGate.GetUnits(), false, 0, 0, 0, 1)) return false;
随后将相应梯度通过临时缓冲区进行累积,并写回 SecondGradient ,确保所有贡献都累积到一个输出流中。
最后,对与 cX_IA 关联的 NeuronOCL 源数据对象调用隐藏梯度计算。这样就完成了误差传播链,并确保所有关于输入数据变化如何影响输出的信息都能准确地传播回模型的开头。
//--- if(!NeuronOCL.CalcHiddenGradients(cX_IA.AsObject())) return false; //--- return true; }
因此,该方法实现了顺序加权机制,用于聚合和传播各个方向的梯度。这样可以确保对像 GinAR 这样复杂的多组件结构进行适当的训练,其中每个输出都会同时影响多个计算分支。
该类及其所有方法的完整代码已附在附件中,供自学使用。如有必要,您可以轻松追踪计算链、组件之间的依赖结构以及每个阶段(从前向传播到反向传播和权重更新)的数据传输逻辑。
我们已经完成了大量工作 — 我们逐步分析了关键组件的架构、方法和内部逻辑,为接下来的步骤奠定了坚实的基础。我建议我们稍作休息,喘口气,让思绪沉淀一下。在下一篇文章中,我们将使这场讨论得出合乎逻辑的结论:我们将从理论转向实践,并利用市场数据来评估所实施解决方案的实际效果。
结论
在本文中,我们对 GinAR 框架的关键组件的架构进行了深入分析。我们逐步检查了组件初始化过程,讨论了前向传播的实现,并详细介绍了反向传播算法。特别关注了注意力模块、图卷积和控制门之间的相互作用,这使得创建灵活且富有表现力的结构成为可能。
这种模块化不仅简化了扩展过程,还为集成更复杂的控制块铺平了道路 — 无论是在强化学习环境中,还是在涉及变量依赖性的预测任务中。
参考
本文中用到的程序
| # | 名称 | 类型 | 描述 |
|---|---|---|---|
| 1 | Study.mq5 | EA | 离线模型训练的 EA |
| 2 | StudyOnline.mq5 | EA | 在线模型训练的 EA |
| 3 | Test.mq5 | EA | 模型测试 EA |
| 4 | Trajectory.mqh | 类库 | 用于描述系统状态和模型架构的结构 |
| 5 | NeuroNet.mqh | 类库 | 用于创建神经网络的类库 |
| 6 | NeuroNet.cl | 程序库 | OpenCL 程序的代码库 |
本文由MetaQuotes Ltd译自俄文
原文地址: https://www.mql5.com/ru/articles/18892
注意: MetaQuotes Ltd.将保留所有关于这些材料的权利。全部或部分复制或者转载这些材料将被禁止。
本文由网站的一位用户撰写,反映了他们的个人观点。MetaQuotes Ltd 不对所提供信息的准确性负责,也不对因使用所述解决方案、策略或建议而产生的任何后果负责。
新手在交易中的10个基本错误
MQL5自动化交易策略(第二十九部分):构建基于价格行为的加特利谐波形态交易系统
这是一篇非常有趣的文章,也是GinAR实现系列的宝贵延续。该架构引起了我们的关注,因为我们目前正在为算法交易 开发一个更广泛的多阶段机器学习研究框架,而我们正在积极探索的领域之一,就是跨资产建模能否提供传统按符号建模无法捕捉的信息。
我们的框架特意被划分为独立的层级:市场数据采集、特征工程、历史数据集构建、模型研究、验证、候选策略生成及执行。 机器学习被视为证据层,而非直接交易授权的来源。因此,模型不能仅仅因为在样本内表现良好就影响下游的交易决策;它必须首先证明其在样本外具有稳定的增量价值。
我们目前处理的多资产范围涵盖外汇、黄金、美国指数、股票和能源品种。历史数据通过“青铜→白银→黄金”管道进行处理,并实施严格的特定时间点控制。决策时刻可用的信息与用于标签和结果分析的未来信息保持分离。
我们当前的黄金训练数据集包含每条观测值142个源特征,其中包括129个数值特征和13个分类特征。特征集涵盖波动率、趋势、动量、市场结构、突破行为、价格走势背景、流动性状况、交易时段状态及其他确定性市场描述符。
在测试 GinAR 之前,我们已经构建了一个基于逻辑回归、XGBoost、LightGBM 和 CatBoost 的第二代模型家族。这些模型通过时间顺序向前验证、阈值筛选、自助法稳定性分析、校准检查、状态分析以及未受干扰的保留集阶段进行了评估。
共有十种交易品种进入我们的深度验证“通过”候选阶段:
GBPUSD、USDCHF、US500、US30、USDCAD、EURUSD、USDJPY、EURGBP、USTEC 和 XAUUSD。
这些成为我们GinAR实验的初始研究样本集。
GinAR之所以特别引起我们的兴趣,在于它引入了与现有模型根本不同的机制。我们当前的GEN2模型主要基于单个交易品种的分类器,而GinAR则能够同时学习多个交易品种之间的动态跨资产关系 及潜在表征。
这一点在金融市场中尤为重要,因为各类金融工具并非相互独立。货币、黄金、股指及其他资产之间的关系会随着市场环境、波动率和交易时段结构的变化而变化。此外,多资产交易体系中自然会存在这样的时期:并非所有金融工具在同一时间都有有效的报价或处于活跃交易时段。
出于这一原因,我们创建了一个完全独立的GinAR 研究分支,该分支不具备交易权限,也不会对现有的执行系统产生影响。
我们的目的并非逐行复现本文中的实现代码,也并非立即替换现有模型。相反,我们希望解答一个更具体的研究问题:
GinAR 风格的跨资产图模型能否提供超越我们现有基于单个符号的 GEN2 模型的额外样本外信息?
因此,我们的实现是一个基于主要架构理念的、受GinAR启发的“洁净室”研究模型:
在首次实验中,我们采用了Gold特征集合同中通用的数值部分,为模型提供了每个节点129个数值特征。
本实验使用的目标数据集是一个时长240分钟的WHIPSAW分类问题。
标签完整性被证明是一个重要的方法论问题。我们的历史标签包含:
WHIPSAW
NO_WHIPSAW
AMBIGUOUS_FIRST_MOVE
以及在某些情况下,INCOMPLETE。
当同一M1小节内同时触发了两个首动障碍时,会出现“AMBIGUOUS_FIRST_MOVE”情况,这意味着无法根据现有源数据确定该小节内的实际顺序。
因此,我们使用了明确的:
AMBIGUOUS_DO_NOT_GUESS
策略。
二进制目标合约变为:
WHIPSAW = 1
NO_WHIPSAW = 0
而 AMBIGUOUS_FIRST_MOVE 和 INCOMPLETE 则被排除在二元损失和评估指标之外,而非被强行归入其中任一类别。
这一点对我们至关重要,因为将模棱两可的结果强行归入其中一类,会引入人为的标签噪声。
我们还实施了严格的反预判控制措施。
验证协议如下:
180天训练
30天验证
60天的向前步法
240分钟的清除/间隔
60天的最终未触及保留集
在最初的GinAR实验中,最终保留集始终处于密封状态。
我们刻意避免了随机训练集/测试集划分。
另一个重要问题涉及缺失变量。在多资产交易环境中,由于交易时段不同,某些交易工具可能会暂时不可用,GinAR 的插值注意力机制在此类场景下可能非常有用。
然而,我们并未 使用该模型来生成历史观测值。
原始数据集中的缺失源观测值仍保持缺失状态,并通过掩码明确表示。内部插值仅作为神经表征的一部分使用。没有将任何重建值写回我们的 Gold 数据集。
随后,我们在6 个按时间顺序排列的向前折叠 中对 GinAR 研究模型进行了训练。
有效目标观测值的数量很大,且在各折叠中相对稳定。由此产生的 OOF 评估产生了大约114,558 个折叠外预测值。
六个折叠中的PR-AUC值分别为:
折1:0.6894
第 2 折:0.6910
第 3 个折:0.6833
第 4 折:0.6866
第5组:0.6584
第6折:0.6999
大多数折叠表现都相当稳定,尽管第5折明显较弱。我们认为这一时期对于后续阶段的分析以及图结构分析尤为重要。
接下来的步骤是我们认为最重要的部分:与我们现有的GEN2顶尖模型进行直接对比。
我们将 GinAR 的 OOF 预测与 GEN2 的相应预测在相同的符号/时间戳交点上进行了对齐。
该比较包含90,122 个匹配的折外观测值。
结果如下:
Delta PR-AUC:-0.0161
Delta LogLoss:+0.0059
鉴于更高的 PR-AUC 和更低的 LogLoss 更理想,当前结果显然更倾向于现有的 GEN2 模型。
换言之,在我们的首次实验中,作为独立的 WHIPSAW 分类器,GinAR 的表现并未优于我们现有的逻辑回归 / XGBoost / LightGBM / CatBoost 模型系列。
因此,我们并未将 GinAR 推广为 GEN2 的替代方案。
不过,我认为这一结果并不意味着 GinAR 对金融应用毫无价值。
事实上,该实验可能表明,该架构最有价值的部分未必是最终的独立分类器。
GinAR 构建了一种跨资产的潜在表示,而我们的独立单符号模型天生并不具备这种能力。
出于这个原因,我们将继续将 GinAR 作为研究编码器,并将下一个研究问题从:
“GinAR能否取代GEN2?”
改为:
“GinAR能否为GEN2提供互补的跨资产信息?”
因此,我们计划进行的下一个实验是:
GEN2基线
对比
GEN2 + GinAR 跨资产嵌入
,采用折叠安全堆叠、概率分歧分析、状态分析、图稳定性分析及消融实验。
我们特别关注以下情况:GEN2优胜模型预测错误而GinAR预测正确,反之亦然。如果这些错误集存在显著差异,那么即使GinAR的独立PR-AUC较低,其表示形式仍可作为额外的特征层发挥作用。
我们还计划考察所学习的邻接矩阵是否随时间保持稳定,或者不同波动率和市场状态下金融工具之间的关系是否会发生实质性变化。
我们认为,这正是基于图的模型在交易系统中可能特别有用的地方。
例如,以下关系的相关性:
黄金 ↔ 美元,
外汇 ↔ 美国股指,
美元兑加元 ↔ 能源,
或 USTEC ↔ 大型科技股
,其相关性未必会随着时间推移而保持恒定。
一个能够动态学习这些关系的模型,即使其直接分类模块并非最强的独立预测因子,仍可能提供有价值的背景信息。
我们从实验中获得的一个方法论启示也值得一提:仅凭训练损失或单次回测结果来评判一种新的神经网络架构是非常容易的。当我们将模型与现有基准在相同的折外样本上进行比较后,我们的结果改变了对该模型的解读。
对我们而言,这种比较至关重要。
不应仅仅因为一个新模型产生了合理的指标,就认为它有用。它必须证明:
因此,现阶段我们的结论是:
GinAR 目前作为独立分类器虽不如我们的 GEN2 模型,但作为跨资产表示和特征生成层,它仍然是一个极具潜力的候选方案。
为了避免在模型开发决策中反复使用保留集,我们特意将最终未被触及的保留集保密,直至下一阶段的研究目标明确为止。
我非常期待看到作者在真实市场数据上进行的后续实验,特别是:
感谢您发布这一系列文章。这是我们在将多变量图学习应用于金融时间序列时所遇到的较为有趣的研究方向之一。
尽管我们首次独立对比并未显示其相对于 GEN2 具有独立优势,但该实验为我们开辟了一个崭新且可能更具实用价值的研究方向:将 GinAR 作为跨资产智能层,而非仅仅是另一个分类器。
我期待下一篇文章,并非常希望将我们的研究结果与作者基于市场数据的实验结果进行对比。