
交易中的神经网络:将全局信息注入独立通道(InjectTST)
概述
近年来,基于变换器架构的多模态时间序列预测已得到广泛普及,并逐渐成为时间序列分析的首选模型之一。模型越来越多地采用独立通道方式,其中模型单独处理每个通道序列,而不与其它通道序列交互。
通道独立性有两个主要优势:
- 噪声抑制:独立模型能专注于各自通道的预测,而不受其它通道噪声的影响。
- 缓解分布漂移:通道独立性有助于解决时间序列中的分布漂移问题。
相反,混合通道在应对这些挑战时往往效果较差,这就会导致模型性能下降。不过,通道混合也具有独特的优势:
- 高信息容量:通道混合模型擅长捕获通道间依赖关系,潜在为预测未来值提供更多信息。
- 通道特异性:在通道混合模型中优化多个通道,令模型能够完全发挥每个通道的各异特征。
甚至,由于独立通道方式通过统一模型分析各个通道,故该模型无法区分通道,专注点主要放在跨多个通道之间的共享形态。这会导致通道各异性的丧失,且或许对多模态时间序列预测产生负面影响。
因此,开发一个结合了通道独立性和混合性两者优势的有效模型 — 能够利用这两种方式(降噪、降低分布漂移、高信息容量、和通道各异性)— 是进一步强化多模态时间序列预测性能的关键。
然而,构建这样的模型是一项复杂的挑战。首先,独立通道模型本质上与通道依赖关系相左。虽然为每个通道优调统一模型能够解决通道各异性问题,但会带来巨大的训练成本。其次,现有的降噪方法和分布漂移解决方案,尚未令通道混合框架如同独立通道模型一样稳健。
论文《InjectTST:将全局信息注入独立通道的变换器方法,进行长时间序列预测》中提出了应对这项挑战的一种潜在解决方案,其中介绍了一种方法,即将全局信息注入多模态时间序列的各个通道(InjectTST)。该方法作者是为避免预测多模态时间序列时,显式建模通道之间的依赖关系。取而代之,它们维护通道独立结构作为基本盘,同时选择性地将全局信息(通道混合)注入每个通道。这将启用隐式通道混合。
每个独立通道都可选择性地接收有用的全局信息,同时避免噪声,维持高信息容量和噪声抑制。由于保留了通道独立性作为基本结构,因此还可减轻分布漂移。
此外,作者在 InjectTST 之中合并了通道标识符,以解决通道各异性问题。
1. InjectTST 算法
为了生成给定水平 T 处的预测 Y,我们分析了包含 L 个时间步的多模态时间序列 X 的历史值,每个时间步都表示为维度 M 的向量。
为了解决利用通道独立性和混合性优势这一任务,采用了复杂的多级 InjectTST 算法。
该算法的第一步涉及将输入数据分段为独立通道的高速公路。之后,应用含有可学习定位编码的线性投影。
独立通道平台以一个共享模型处理每个通道。如是结果,该模型无法在通道之间区分,主要学习通道的常见形态,缺乏通道各异性。为解决该问题,InjectTST 的作者引入了一个通道标识符,其是一个可学习张量。
在补片的线性投影之后,添加了含有定位编码和通道标识符两者的张量。
然后将这些准备好的数据投喂到变换器编码器中进行高等级表示。
重点要注意,在这种情况下,变换器编码器在独立通的道高速公路上运作,意味着只分析各个通道的令牌,通道之间没有信息交换。
通道标识符表示每个通道的特异特征,令模型能够区分它们,并为每个通道获取唯一的表示。
同时,通道混合路径与独立通道的高速公路并排,经由全局混流模块通验原始序列 X,从而获取全局信息。InjectTST 的主要目标是将全局信息注入每个通道,令全局信息的提取成为一项关键任务。该方法作者提出了两种类型的全局混合模块,称为 CaT(Channel as Token — 通道作为令牌),和 PaT(Patch as Token — 补片作为令牌)。
CaT 模块直接将每个通道投影到一个令牌中。简言之,线性投影被应用于通道中的所有值。
PaT 全局混合模块把补片作为输入处理。最初,与所分析多模态序列的相应时间步长有关的补片被分组。然后,将线性投影应用到已分组的补片,这主要是合并补片级别的信息。然后添加定位编码,并将数据传递给变换器编码器,进化跨补片和全局信息的信息集成。
作者进行的实验指明,PaT 更稳定,而 CaT 在某些专业数据集上执行更佳。
InjectTST 方法的一个关键挑战是需要将全局信息注入每个通道,同时对模型可靠性的影响最小。在原版变换器中,交叉注意允许目标序列基于相关性有选择地专注于来自另一来源的上下文信息。这种对交叉注意力架构的理解,也可应用于从多模态时间序列中注入全局信息。因此,跨通道混合的全局信息,能作为上下文应对。作者使用交叉注意力将全局信息注入每个通道。
值得注意的是,作者为上下文注意力模块引入了一个可选的残差连接。典型情况,残差连接会令模型略微不稳定,但它们能显著提高某些专用数据集的性能。
通常,全局信息作为键和数值引入上下文注意力模块,而特定于通道的信息则代表查询。
交叉注意力之后,数据会因全局信息而更丰富。添加一个线性头来生成预测值。
InjectTST 作者提出了一个三阶段训练过程。在预训练阶段,原始时间序列被随机掩盖,目标是预测被遮盖的部分。在优调阶段,预训练的 InjectTST 头被替换为预测头,预测头被优调,而网络的其余部分被冻结。最后,在优调阶段,整个 InjectTST 网络经受优调。
该方法的原始可视化如下所示。
2. 利用 MQL5 实现
在回顾了 InjectTST 方法的理论层面之后,我们转到所提议方法的 MQL5 实际实现。
重点要注意,本文中提供的实现并非唯一正确。甚至,所提议实现反映的是我个人对原始论文中介绍材料的理解,或许与作者所提议方式的看法不同。这同样体现在获得的结果。
在开工实现所提议方式时,重点要强调,我们之前已用独立通道范式验证了若干个基于变换器模型。在这些模型中,针对独立通道进行预测,并用变换器模块来研究通道间依赖关系,这类似于 CaT 全局混合模块方式。
不过,该方法作者在独立通道的高速公路中采用了变换器架构,避免了该阶段在通道之间的信息流动。理论上,我们可以通过处理分离的单独序列中的数据来实现该算法。不过,该方式太宽泛,会导致顺序运算的数量增加,其会随着多模态输入数据中所分析变量数量而增长。
在我们的工作中,我们的靶标是在并行线程中执行尽可能多的运算。因此,在该实现中,我们将创建一个新层,允许独立分析各个通道。
2.1独立通道分析模块
独立通道分析的功能在 CNeuronMVMHAttentionMLKV 类中实现,其继承了另一个多层多头注意力模块 CNeuronMLMHAttentionOCL 的基本功能。新类结构如下所示。
class CNeuronMVMHAttentionMLKV : public CNeuronMLMHAttentionOCL { protected: uint iLayersToOneKV; ///< Number of inner layers to 1 KV uint iHeadsKV; ///< Number of heads KV uint iVariables; ///< Number of variables CCollection KV_Tensors; ///< The collection of tensors of Keys and Values CCollection K_Tensors; ///< The collection of tensors of Keys CCollection K_Weights; ///< The collection of Matrix of K weights to previous layer CCollection V_Tensors; ///< The collection of tensors of Values CCollection V_Weights; ///< The collection of Matrix of V weights to previous layer CBufferFloat Temp; //--- virtual bool feedForward(CNeuronBaseOCL *NeuronOCL) override; virtual bool AttentionOut(CBufferFloat *q, CBufferFloat *kv, CBufferFloat *scores, CBufferFloat *out); virtual bool AttentionInsideGradients(CBufferFloat *q, CBufferFloat *q_g, CBufferFloat *kv, CBufferFloat *kv_g, CBufferFloat *scores, CBufferFloat *gradient); //--- virtual bool calcInputGradients(CNeuronBaseOCL *prevLayer) override; virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL) override; public: CNeuronMVMHAttentionMLKV(void) {}; ~CNeuronMVMHAttentionMLKV(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, uint variables, ENUM_OPTIMIZATION optimization_type, uint batch); //--- virtual int Type(void) const { return defNeuronMVMHAttentionMLKV; } //--- virtual bool Save(int const file_handle); virtual bool Load(int const file_handle); virtual bool WeightsUpdate(CNeuronBaseOCL *source, float tau); virtual void SetOpenCL(COpenCLMy *obj); };
在该类中,我们添加了 3 个变量:
- iLayersToOneKV — 1 个键值张量的层数;
- iHeadsKV — 键值张量中的注意力头数量;
- iVariables — 多模态时间序列中单变量序列的数量。
此外,我们还添加了 5 个数据缓冲区集合,我们将在实现过程中学习其目的。所有内部对象都声明为静态,这允许类构造函数和析构函数保持“空”。所有内部变量和对象的初始化都在 Init 方法中执行。
bool CNeuronMVMHAttentionMLKV::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, uint variables, ENUM_OPTIMIZATION optimization_type, uint batch) { if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, window * units_count * variables, optimization_type, batch)) return false;
在该方法的参数中,我们期待接收主要常量,允许我们唯一标识所初始化类的架构。这些包括:
- window — 代表一个单变量时间序列序列一个元素的向量大小;
- window_key — 单变量时间序列序列的一个元素的键实体的内部表示向量的大小;
- heads ― 查询实体的注意力头数量;
- heads_kv — 级联的键值张量中的注意力头数量;
- units_count ― 正在分析的序列大小;
- layers — 模块中嵌套层的数量;
- layers_to_one_kv — 搭配一个键值张量操作的嵌套层数量;
- variables — 多模态时间序列中单变量序列的数量。
在方法主体中,我们首先调用父类的同名方法,其控制接收到的参数,并初始化继承对象。此外,该方法已实现了针对自调用方所接收数据的最低必要控制。
父类方法成功执行之后,我们将接收到的参数保存在内部变量当中。
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); iVariables = variables;
在此,我们定义了确定嵌套对象架构的主要常量。
uint num_q = iWindowKey * iHeads * iUnits * iVariables; //Size of Q tensor uint num_kv = iWindowKey * iHeadsKV * iUnits * iVariables; //Size of KV tensor uint q_weights = (iWindow * iHeads + 1) * iWindowKey; //Size of weights' matrix of Q tenzor uint kv_weights = (iWindow * iHeadsKV + 1) * iWindowKey; //Size of weights' matrix of K/V tenzor uint scores = iUnits * iUnits * iHeads * iVariables; //Size of Score tensor uint mh_out = iWindowKey * iHeads * iUnits * iVariables; //Size of multi-heads self-attention uint out = iWindow * iUnits * iVariables; //Size of out tensore uint w0 = (iWindowKey * iHeads + 1) * iWindow; //Size W0 weights' matrix 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
在此,简要讨论我们为该类中实现所提议方式非常重要。首要的是,我们决定在构造新类时,无需修改 OpenCL 程序。换言之,尽管有新的需求,我们还是完全使用现有内核来构建类。
为了达成这一点,我们从分离键和值实体的生成开始。如是提醒,早前它们是经由单次卷积层通验生成的,并针对每个序列元素按顺序写入缓冲区。在构造全局注意力时,这种方式是可以接受的。然而,当在单独通道中组织过程时,我们会获得各个通道的键值交替序列,这对于后续分析来说并不理想,也与之前创建的算法不合。因此,我们分别生成这些实体,然后将它们级联到一个张量。
值得注意的是,我们将实体的生成分为两个阶段,其数量与所分析变量、或注意力头数量无关。
第二点是 InjectTST 方法的作者针对所有通道使用单一变换器编码器。类似地,我们针对所有通道使用单组权重矩阵。如是结果,无关通道数量,权重矩阵的大小都保持常数。
据此,我们的准备工作就完成了,我们继续组织一个迭代次数等于嵌套层数量的循环。
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;
在此,我们首先创建 Query 张量缓冲区。所有缓冲区的创建算法都雷同。首先,我们创建缓冲区对象的新实例。我们按给定大小及零值初始化它。然后,我们在 OpenCL 关联环境中创建一个缓冲区副本,并添加指向相应集合的缓冲区指针。不要忘记控制每个步骤的操作。
鉴于我们计划分析若干嵌套层时使用 1 个键-值张量,因此我们按给定频率创建相应的缓冲区。
//--- Initilize KV tensor if(i % iLayersToOneKV == 0) { 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(!K_Tensors.Add(temp)) return false; 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(!V_Tensors.Add(temp)) return false; temp = new CBufferFloat(); if(CheckPointer(temp) == POINTER_INVALID) return false; if(!temp.BufferInit(2 * num_kv, 0)) return false; if(!temp.BufferCreate(OpenCL)) return false; if(!KV_Tensors.Add(temp)) return false; }
注意,在该阶段,我们创建 3 个缓冲区:Key、Value、和级联的 Key-Value。
下一步是创建注意力系数缓冲区。
//--- 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;
然后是多头注意力和 FeedForward 模块的压缩缓冲区。
//--- 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; //--- 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; }
初始化中间结果缓冲区及其梯度之后,我们转到初始化权重矩阵。它们的初始化算法类似于创建数据缓冲区,仅在于矩阵由随机值填充。
生成的第一个矩阵是 Query 实体的权重矩阵。
//--- Initialize Q weights temp = new CBufferFloat(); if(CheckPointer(temp) == POINTER_INVALID) return false; if(!temp.Reserve(q_weights)) return false; float k = (float)(1 / sqrt(iWindow + 1)); for(uint w = 0; w < q_weights; w++) { if(!temp.Add(GenerateWeight() * 2 * k - k)) return false; } if(!temp.BufferCreate(OpenCL)) return false; if(!QKV_Weights.Add(temp)) return false;
Key 和 Value 实体的权重矩阵的创建频率类似于相应实体的缓冲区频率。
//--- Initialize K weights if(i % iLayersToOneKV == 0) { temp = new CBufferFloat(); if(CheckPointer(temp) == POINTER_INVALID) return false; if(!temp.Reserve(kv_weights)) return false; float k = (float)(1 / sqrt(iWindow + 1)); for(uint w = 0; w < kv_weights; w++) { if(!temp.Add(GenerateWeight() * 2 * k - k)) return false; } if(!temp.BufferCreate(OpenCL)) return false; if(!K_Weights.Add(temp)) return false; //--- temp = new CBufferFloat(); if(CheckPointer(temp) == POINTER_INVALID) return false; if(!temp.Reserve(kv_weights)) return false; for(uint w = 0; w < kv_weights; w++) { if(!temp.Add(GenerateWeight() * 2 * k - k)) return false; } if(!temp.BufferCreate(OpenCL)) return false; if(!V_Weights.Add(temp)) return false; }
我们添加一个注意力头的压缩矩阵。
//--- Initialize Weights0 temp = new CBufferFloat(); if(CheckPointer(temp) == POINTER_INVALID) return false; if(!temp.Reserve(w0)) return false; 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;
和 FeedForward 模块。
//--- Initialize 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 ? q_weights : iWindowKey * iHeads), 0)) return false; if(!temp.BufferCreate(OpenCL)) return false; if(!QKV_Weights.Add(temp)) return false; if(i % iLayersToOneKV == 0) { temp = new CBufferFloat(); if(CheckPointer(temp) == POINTER_INVALID) return false; if(!temp.BufferInit((d == 0 || optimization == ADAM ? kv_weights : iWindowKey * iHeadsKV), 0)) return false; if(!temp.BufferCreate(OpenCL)) return false; if(!K_Weights.Add(temp)) return false; //--- temp = new CBufferFloat(); if(CheckPointer(temp) == POINTER_INVALID) return false; if(!temp.BufferInit((d == 0 || optimization == ADAM ? kv_weights : iWindowKey * iHeadsKV), 0)) return false; if(!temp.BufferCreate(OpenCL)) return false; if(!V_Weights.Add(temp)) return false; } 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(2 * num_kv, out), 0)) return false; if(!Temp.BufferCreate(OpenCL)) return false; //--- return true; }
对象初始化之后,我们转到组织前向通验算法。关于使用以前创建的内核,值得于此说几句。特别是关于交叉注意力模块 MH2AttentionOut 的前馈通验内核,将其放入执行队列的算法是在 AttentionOut 方法中实现的。将内核放入执行队列的算法没有改变。但我们的任务是用这个算法实现对独立通道的分析。
首先,我们来看看内核是如何与单个注意力头打交道的。它在单独的流中独立处理它们。我认为这正是我们所需要的。故此,我们可以说各个通道是相同的注意力头。
bool CNeuronMVMHAttentionMLKV::AttentionOut(CBufferFloat *q, CBufferFloat *kv, CBufferFloat *scores, CBufferFloat *out) { if(!OpenCL) return false; //--- uint global_work_offset[3] = {0}; uint global_work_size[3] = {iUnits/*Q units*/, iUnits/*K units*/, iHeads * iVariables}; uint local_work_size[3] = {1, iUnits, 1};
否则,该方法的算法保持不变。我们将必要的参数传递给内核。
ResetLastError(); if(!OpenCL.SetArgumentBuffer(def_k_MH2AttentionOut, def_k_mh2ao_q, q.GetIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgumentBuffer(def_k_MH2AttentionOut, def_k_mh2ao_kv, kv.GetIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgumentBuffer(def_k_MH2AttentionOut, def_k_mh2ao_score, scores.GetIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgumentBuffer(def_k_MH2AttentionOut, def_k_mh2ao_out, out.GetIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgument(def_k_MH2AttentionOut, def_k_mh2ao_dimension, (int)iWindowKey)) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; }
调整 键-值 张量头的数量。
if(!OpenCL.SetArgument(def_k_MH2AttentionOut, def_k_mh2ao_heads_kv, (int)(iHeadsKV * iVariables))) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; }
然后将内核放入执行队列当中。
if(!OpenCL.SetArgument(def_k_MH2AttentionOut, def_k_mh2ao_mask, 0)) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.Execute(def_k_MH2AttentionOut, 3, global_work_offset, global_work_size, local_work_size)) { printf("Error of execution kernel %s: %d", __FUNCTION__, GetLastError()); return false; } //--- return true; }
这样就完成了该方法。但这只是前馈通验算法的一部分。我们将在 feedForward 方法中构建完整的算法。
bool CNeuronMVMHAttentionMLKV::feedForward(CNeuronBaseOCL *NeuronOCL) { if(CheckPointer(NeuronOCL) == POINTER_INVALID) return false;
在参数中,该方法接收一个指向前一个神经层对象的指针,其中包含我们算法的初始数据。作为初始数据,我们期望收到一个三维张量 — 序列的长度 * 单变量序列的数量 * 一个元素的分析窗口大小。
在方法主体中,我们检查收到的指针相关性,并组织一个循环遍历模块的嵌套层。
CBufferFloat *kv = NULL; for(uint i = 0; (i < iLayers && !IsStopped()); i++) { //--- Calculate Queries, Keys, Values CBufferFloat *inputs = (i == 0 ? NeuronOCL.getOutput() : FF_Tensors.At(6 * i - 4));
在此,我们首先声明一个指向源数据缓冲区的局部指针,我们将把所需的指针存储到该缓冲区之中。之后,我们从集合中提取与分析层对应的 Query 实体缓冲区,并将基于原始数据生成的数据写入其中。
CBufferFloat *q = QKV_Tensors.At(i * 2); if(IsStopped() || !ConvolutionForward(QKV_Weights.At(i * (optimization == SGD ? 2 : 3)), inputs, q, iWindow, iWindowKey * iHeads, None)) return false;
下一步我们将检查是否需要生成新的 Key-Value 张量。如有必要,我们将首先判定相关集合中的偏移量。
if((i % iLayersToOneKV) == 0) { uint i_kv = i / iLayersToOneKV;
然后我们提取指向所需缓冲区的指针。
kv = KV_Tensors.At(i_kv * 2); CBufferFloat *k = K_Tensors.At(i_kv * 2); CBufferFloat *v = V_Tensors.At(i_kv * 2);
之后,我们将按顺序生成 Key 和 Value 实体。
if(IsStopped() || !ConvolutionForward(K_Weights.At(i_kv * (optimization == SGD ? 2 : 3)), inputs, k, iWindow, iWindowKey * iHeadsKV, None)) return false; if(IsStopped() || !ConvolutionForward(V_Weights.At(i_kv * (optimization == SGD ? 2 : 3)), inputs, v, iWindow, iWindowKey * iHeadsKV, None)) return false;
我们沿第一维(序列的元素)级联获得的张量。
if(IsStopped() || !Concat(k, v, kv, iWindowKey * iHeadsKV * iVariables, iWindowKey * iHeadsKV * iVariables, iUnits)) return false; }
请注意,在这个版本的数据组织中,我们得到了一个数据缓冲区,它可以表示为一个五维数据张量:Units * [Key, Value] * Variable * HeadsKV * Window_Key。Query 实体张量拥有可比较的维度,我们仅用 [Query],替代了 [Key, Value]。通过将 Variable 和 Heads 的维度聚合在一个维度 “Variable * Heads” 中,我们得到了与原版多头自我注意力相当的张量维度。
此处必须要提醒,在 OpenCL 关联环境端,我们是配合一维数据缓冲区工作。将数据拆分为多维张量,仅是声明性,出于理解数据序列。通常,缓冲区中的数据序列从最后一个维度到第一个维度。
这允许我们使用之前创建的 OpenCL 程序内核来分析独立通道。我们从集合中获取指向所需数据缓冲区的指针,并执行多头自注意力算法。我们已调整了以上所需方法。
//--- Score calculation and Multi-heads attention calculation CBufferFloat *temp = S_Tensors.At(i * 2); CBufferFloat *out = AO_Tensors.At(i * 2); if(IsStopped() || !AttentionOut(q, kv, temp, out)) return false;
然后,我们在脑海中将多头注意力的结果重新格式化为 [Units * Variable] * Heads * Window_Key 的张量,并将数据投影到原始数据的维度。
//--- Attention out calculation temp = FF_Tensors.At(i * 6); if(IsStopped() || !ConvolutionForward(FF_Weights.At(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.At(i * 6 + 1); if(IsStopped() || !ConvolutionForward(FF_Weights.At(i * (optimization == SGD ? 6 : 9) + 1), inputs, temp, iWindow, 4 * iWindow, LReLU)) return false; out = FF_Tensors.At(i * 6 + 2); if(IsStopped() || !ConvolutionForward(FF_Weights.At(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; } //--- return true; }
成功完成模块中所有嵌套层的操作之后,我们完结方法的执行,并将逻辑结果返回给调用程序,指示操作的完成状态。
通常,在实现前馈通验方法之后,我们会继续开发反向传播算法。今天,我想邀请您独立分析一下所提议实现,您可以在随附的素材中找到。在实现反向传播方法的过程中,我们利用了早前讲述过的前馈通验的相同方式。重点要注意,反向传播操作严格遵循前馈通验算法,但顺序逆反。
此外,该附件还包含 CNeuronMVCrossAttentionMLKV 类的实现,其算法在很大程度上镜像 CNeuronMVMHAttentionMLKV 类的算法,关键补充是交叉注意机制。
我还想提醒您,所实现类,CNeuronMVMHAttentionMLKV 和 CNeuronMVCrossAttentionMLKV,当作更大的 InjectTST 算法内的构建模块,我们早前曾探讨过其理论层面。我们的下一步工作将是开发一个新类,我们于其中将实现完整的 InjectTST 算法。
2.2InjectTST 的实现
我们将在 CNeuronInjectTST 类中构建完整的 InjectTST 算法,其将继承全连接神经层父类 CNeuronBaseOCL 的核心功能。新类结构如下所示。
class CNeuronInjectTST : public CNeuronBaseOCL { protected: CNeuronPatching cPatching; CNeuronLearnabledPE cCIPosition; CNeuronLearnabledPE cCMPosition; CNeuronMVMHAttentionMLKV cChanelIndependentAttention; CNeuronMLMHAttentionMLKV cChanelMixAttention; CNeuronMVCrossAttentionMLKV cGlobalInjectionAttention; CBufferFloat cTemp; //--- virtual bool feedForward(CNeuronBaseOCL *NeuronOCL) override; //--- virtual bool calcInputGradients(CNeuronBaseOCL *NeuronOCL) override; virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL) override; //--- public: CNeuronInjectTST(void) {}; ~CNeuronInjectTST(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, uint variables, ENUM_OPTIMIZATION optimization_type, uint batch); //--- virtual int Type(void) const { return defNeuronInjectTST; } //--- virtual bool Save(int const file_handle); virtual bool Load(int const file_handle); //--- virtual bool WeightsUpdate(CNeuronBaseOCL *source, float tau); virtual void SetOpenCL(COpenCLMy *obj); //--- virtual CBufferFloat *getWeights(void) override; };
在该类中,我们看到了相当多的内部对象,但没有一个单独变量。事实上这是因为该类的实现,或可说,一个“大节点组装”算法,其主要功能由内部对象构建。定义模块架构的所有常量,仅在类初始化方法中用到,并存储在嵌套对象之内。在实现算法的过程中,我们将领略这些功能。
类的所有内部对象都声明为静态,这允许我们将类构造函数和析构函数留空。所有嵌套和继承对象的初始化都在 Init 方法中执行。
bool CNeuronInjectTST::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, uint variables, ENUM_OPTIMIZATION optimization_type, uint batch) { if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, window * units_count * variables, optimization_type, batch)) return false; SetActivationFunction(None);
如常,在该方法参数中,我们接收确定所创建对象架构的主要常量。在方法主体中,我们立即调用父类同名方法,其中已实现了针对所接收参数和继承对象的初始化的基本控制。
接下来,我们在 InjectTST 算法的前馈通验中初始化内部对象。在作者对上述方法的可视化中,很容易看出获得的初始数据用在 2 个信息流:独立通道和全局混合模块。在这两个模块当中,源数据首先被分段。在我的实施中,我决定不复制分段过程,但会在信息流分支之前执行一次。
if(!cPatching.Init(0, 0, OpenCL, window, window, window, units_count, variables, optimization, iBatch)) return false; cPatching.SetActivationFunction(None);
应该注意,在该实现中,我用到相等的参数:分段大小、分段窗口步、和分段嵌入大小。因此,分段前后的源数据缓冲区的大小没有变化。不过,缓冲区中的数据序列已变化。来自两个维度 L * V 的数据张量被重新格式化为三个维度 L/p * V * p,其中 L 是初始数据的多模态序列的长度,V 是所分析变量数量,p 是分段大小。
对于独立通道主干模块中的分段令牌,该方法作者添加了两个可训练张量:定位编码,和通道标识。这两个数字之和是一个数字,故在我的实现中,我决定使用一个可学习的定位编码层来学习输入张量中每个单独元素的定位标签。
if(!cCIPosition.Init(0, 1, OpenCL, window * units_count * variables, optimization, iBatch)) return false; cCIPosition.SetActivationFunction(None);
在全局混合模块中,该算法还提供定位编码。我们为第二条信息流高速公路初始化一个类似的层。
if(!cCMPosition.Init(0, 2, OpenCL, window * units_count * variables, optimization, iBatch)) return false; cCMPosition.SetActivationFunction(None);
我们将用上面所讨论独立通道 CNeuronMVMHAttentionMLKV 注意力模块构造独立通道主干。
if(!cChanelIndependentAttention.Init(0, 3, OpenCL, window, window_key, heads, heads_kv, units_count, layers, layers_to_one_kv, variables, optimization, iBatch)) return false; cChanelIndependentAttention.SetActivationFunction(None);
为了规划全局混合模块,我们将用到之前创建的注意力模块 CNeuronMLMHAttentionMLKV。
if(!cChanelMixAttention.Init(0, 4, OpenCL, window * variables, window_key, heads, heads_kv, units_count, layers, layers_to_one_kv, optimization, iBatch)) return false; cChanelMixAttention.SetActivationFunction(None);
注意,在这种情况下,一个元素的分析向量窗口大小,等于分段大小,和所分析变量的数量乘积,这对应于通道混合范式。
将全局信息注入独立通道,是在交叉注意力模块内进行的。
if(!cGlobalInjectionAttention.Init(0, 5, OpenCL, window, window_key, heads, window * variables, heads_kv, units_count, units_count, layers, layers_to_one_kv, variables, 1, optimization, iBatch)) return false; cGlobalInjectionAttention.SetActivationFunction(None);
注意,在这种情况下,我们在上下文中将集中行数设置为 1,因为我们在此是搭配混合通道工作。
在初始化方法的末尾,我们执行数据缓冲区交换,这可令我们避免在类的缓冲区和内部对象之间进行不必要的复制。
if(!SetOutput(cGlobalInjectionAttention.getOutput(), true) || !SetGradient(cGlobalInjectionAttention.getGradient(), true) ) return false;
我们初始化一个存储中间数据的辅助缓冲区,并将操作的逻辑结果返回给调用程序。
if(!cTemp.BufferInit(cPatching.Neurons(), 0) || !cTemp.BufferCreate(OpenCL) ) return false; //--- return true; }
类对象初始化之后,我们转到为类构建前馈通验算法。我们在实现初始化方法的过程中已讨论过算法的主要阶段。现在我们只需在 feedForward 方法中描述它们。
bool CNeuronInjectTST::feedForward(CNeuronBaseOCL *NeuronOCL) { if(!cPatching.FeedForward(NeuronOCL)) return false;
在方法参数中,我们收到一个指向上一层对象的指针,其中为我们传递原始数据。我们立即将接收到的指针传递至嵌套数据分段层的同名方法。
注意,在该阶段,我们不会检查所获取指针的相关性,因为必要的控制是在分段层方法中实现的,故无需重复检查。
下一步是向分段数据添加定位编码。
if(!cCIPosition.FeedForward(cPatching.AsObject()) || !cCMPosition.FeedForward(cPatching.AsObject()) ) return false;
之后,我们首先经由一个独立通道模块通验数据。
if(!cChanelIndependentAttention.FeedForward(cCIPosition.AsObject())) return false;
然后经由全局混合模块。
if(!cChanelMixAttention.FeedForward(cCMPosition.AsObject())) return false;
请注意,尽管执行顺序不同,但这是 2 个独立的信息流。只有在上下文注意力模块当中,才会运作全局数据注入独立通道。
if(!cGlobalInjectionAttention.FeedForward(cCIPosition.AsObject(), cCMPosition.getOutput())) return false; //--- return true; }
我们已将决策过程移出 CNeuronInjectTST 类之外。
正如您所见,前馈通验方法被证明是相当简洁、并具可读性。换言之,正如算法的大节点实现所预期。向后通验方法的构造方式与此类似。您可在附件中自行找到代码。附件中提供了该类及其所有方法的完整代码。附件还包含本文中用到的所有程序的完整代码。
2.3可训练模型的架构
上面我们已经按 MQL5 手段实现了 InjectTST 方法的基本算法,现在我们就能在我们自己的模型中实现所提议方法。我们正在研究的方法是为预测时间序列而提出的。与之前研究的许多预测时间序列的方法类似,我们将尝试在环境状态编码器模型中实现所提议方法 。如您所知,该模型的架构描述已在 CreateEncoderDescriptions 方法中提供。
bool CreateEncoderDescriptions(CArrayObj *&encoder) { //--- CLayerDescription *descr; //--- if(!encoder) { encoder = new CArrayObj(); if(!encoder) return false; }
在该方法参数中,我们收到一个指向动态数组对象的指针,是为记录模型架构。在方法主体中,我们立即检查所接收指针的相关性,并在必要时创建一个新的动态数组对象。然后我们开始描述正在创建的模型架构。
首先是基本的全连接层,其为记录原始数据。
//--- Input layer if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; int prev_count = descr.count = (HistoryBars * BarDescr); descr.activation = None; descr.optimization = ADAM; if(!encoder.Add(descr)) { delete descr; return false; }
如常,我们计划为模型投喂原生输入数据。它们在批处理数据归一化层中进行初级处理,其中来自不同分布的信息被转换为可比较的形式。
//--- layer 1 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBatchNormOCL; descr.count = prev_count; descr.batch = 1e4; descr.activation = None; descr.optimization = ADAM; if(!encoder.Add(descr)) { delete descr; return false; }
接下来是我们的含有全局注入的新独立通道层。
//--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronInjectTST; descr.window = PatchSize; //Patch window descr.window_out = 8; //Window Key
为了指定分段大小,我们添加 PatchSize 常量。我们基于所分析历史的深度和分段的大小,来计算序列的大小。
prev_count = descr.count = (HistoryBars + descr.window - 1) / descr.window; //Units
Query、Key 和 Value 实体的注意力头数量,我们还会将统合序列的数量写入数组。
{ int temp[] = { 4, //Heads 2, //Heads KV BarDescr //Variables }; ArrayCopy(descr.heads, temp); }
所有内部模块都将包含 4 个折叠层。
descr.layers = 4; //Layers
一个 键-值 张量将与 2 个嵌套层相关。
descr.step = 2; //Layers to 1 KV descr.batch = 1e4; descr.activation = None; descr.optimization = ADAM; if(!encoder.Add(descr)) { delete descr; return false; }
接下来,我们需要添加一个预测后续值的头。我们记得,在 InjectTST 模块的输出处,我们得到了一个维度为 L/p * V * p 的张量。为了在独立通道内进行数据预测,我们首先需要对数据进行转置。
//--- layer 3 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronTransposeOCL; descr.count = prev_count; descr.window = PatchSize * BarDescr; descr.activation = None; descr.optimization = ADAM; if(!encoder.Add(descr)) { delete descr; return false; }
然后我们用两层 MLP 来预测独立通道。
//--- layer 4 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConvOCL; descr.count = PatchSize * BarDescr; descr.window = prev_count; descr.window_out = NForecast; descr.activation = LReLU; descr.optimization = ADAM; if(!encoder.Add(descr)) { delete descr; return false; } //--- layer 5 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConvOCL; descr.count = BarDescr; descr.window = PatchSize * NForecast; descr.window_out = NForecast; descr.activation = TANH; descr.optimization = ADAM; if(!encoder.Add(descr)) { delete descr; return false; }
如此行事,我们把数据的维度降低至 Variables * Forecast。现在我们能将预测值返回到原始数据表示形式。
//--- layer 6 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronTransposeOCL; descr.count = BarDescr; descr.window = NForecast; descr.activation = None; descr.optimization = ADAM; if(!encoder.Add(descr)) { delete descr; return false; }
我们重新加上在归一化过程中从原始数据中删除的统计指标。
//--- layer 7 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronRevInDenormOCL; descr.count = BarDescr * NForecast; descr.activation = None; descr.optimization = ADAM; descr.layers = 1; if(!encoder.Add(descr)) { delete descr; return false; }
此外,我们运用 FreDF 方法在频域中对齐单变量序列的预测值。
//--- layer 8 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronFreDFOCL; descr.window = BarDescr; descr.count = NForecast; descr.step = int(true); descr.probability = 0.7f; descr.activation = None; descr.optimization = ADAM; if(!encoder.Add(descr)) { delete descr; return false; } //--- return true; }
我采用了之前作品中的参与者和评论者模型架构。因此,我们现将不再赘述。
甚至,在新的编码器架构中,我们并未更改源数据层、以及结果来判定账户状态。所有这些都令我们能够使用所有之前创建的程序,与环境交互,并训练模型,而无需任何更改。相应地,我们能用之前收集的训练样本进行模型的初始训练。
您可在附件中找到所有类及其方法的完整代码,以及准备本文时用到的所有程序。
3. 测试
如上,我们利用 MQL5 实现了 InjectTST 方法,并演示了它在环境状态编码器模型中的应用。现在,我们继续评估模型据真实历史数据的有效性。
如前,我们首先训练环境状态编码器模型,预测指定横向范围内的未来价格走势。在本实验中,训练数据集包含 EURUSD 工具 2023 年 H1 时间帧的历史数据。
环境状态编码器仅分析历史价格数据,这些数据不受个体动作的影响。因此,我们训练模型,直到我们达成令人满意的结果、或预测误差停滞。
以下是预测和实际价格走势轨迹的对比可视化。
如图所示,预测的轨迹向上偏移,所示波动不明显。不过,总体趋势方向与目标轨迹并肩。与之前探索的模型相比,或许这不是最准确的预测,我们转到训练的第二阶段,以便评估该编码器是否可以帮助参与者开发可盈利策略。
训练参与者和评论者模型是迭代执行的。最初,我们用现有的训练数据集进行若干局次的模型训练。然后,在与环境交互期间,我们基于参与者当前政策下从动作中获得的奖励更新数据集。这令我们能够据从当前参与者政策分派的实际动作奖励来丰富训练集。这样据真实奖励值来丰富训练数据集,可以更好地优化评论者的奖励函数,并更精确地评估参与者的动作。这反过来又能进行调整,从而提升当前政策的有效性。迭代继续,直到达成所需的成果。
为了评估已训练的参与者政策有效性,我们在 MetaTrader 5 策略测试器内测试运行了环境交互 EA。测试执行的历史数据来自 2024 年 1 月,同时保持所有其它参数不变。测试运行的结果如下所示。
在测试期间,该模型达成了小额盈利。共执行了 59 笔交易,其中 30 笔盈利。最大和平均盈利交易超过了对应亏损交易。其结果的盈利因子为 1.05。然而,余额曲线缺乏明显的上升趋势,且在测试期间记录到超过 33% 的回撤。
结束语
在本文中,我们探讨了 InjectTST,这是一种新颖的时间序列预测方法,旨在通过将全局信息注入独立数据通道来强化长期预测。
在实践章节,我们利用 MQL5 实现了所提议方式,并将它们集成到环境状态编码器模型之中。虽然大量工作已完成,但成果没有达到我们的预期。
需要进行全面分析,以便判定模型性能不佳的原因。不过,一个潜在原因或许是在训练环境状态预测模型时采用的直接方式。InjectTST 的作者原本推荐一个三阶段的训练过程,这或许是达成更佳成果所必需的。
参考
文章中所用程序
# | 名称 | 类型 | 说明 |
---|---|---|---|
1 | Research.mq5 | 智能系统 | 样本收集 EA |
2 | ResearchRealORL.mq5 | 智能系统 | 利用 Real ORL方法收集样本的 EA |
3 | Study.mq5 | 智能系统 | 模型训练 EA |
4 | StudyEncoder.mq5 | 智能系统 | 编码器训练 EA |
5 | Test.mq5 | 智能系统 | 模型测试 EA |
6 | Trajectory.mqh | 类库 | 系统状态描述结构 |
7 | NeuroNet.mqh | 类库 | 创建神经网络的类库 |
8 | NeuroNet.cl | 代码库 | OpenCL 程序代码库 |
本文由MetaQuotes Ltd译自俄文
原文地址: https://www.mql5.com/ru/articles/15498



