
神经网络变得简单(第 73 部分):价格走势预测 AutoBot
概述
有效预测货币对的走势是安全交易管理的一个关键方面。在这种情况下,要特别关注开发有效的模型,即可以准确地近似制定交易决策所需的语境联合分布,和时序信息。作为该类任务的可能解决方案,我们讨论一种称为《潜变量顺序集合转换器》(AutoBots)的新方法,该方法是在论文《联合多个体运动预测的潜变量顺序集合转换器》中表述。所提议方法基于编码器-解码器架构。开发它是为了解决机器人系统的安全控制问题。它允许为多名个体生成与场景一致的轨迹序列。AutoBots 能预测一个自主个体的轨迹,或场景中所有个体的未来轨迹分布。在我们的情况中,我们将尝试应用所提议的模型来生成与市场动态一致的货币对价格走势序列。
1. AutoBots 算法
“潜变量顺序集合转换器”(AutoBots)是一种基于编码器-解码器架构的方法。它处理集合序列。把一个集合序列 X1:t = (X1, …, Xt) 馈送到 AutoBot,在预测走势的问题中,可以将其视为 t 个时间步长的环境状态。每组包含 M 个元素(个体、金融产品、和/或指标),伴以 K 属性(符号)。为了在编码器中处理社团和时序信息,用到了以下两种转换。
首先,AutoBots 编码器使用正弦位置编码函数 PE(.) 将时序信息引入一个集合系列之中。在此阶段,数据将分析为矩阵的集合,{X0, …, XM},其描述了个体随时间的演化。编码器使用多目关注模块处理集合之间的时序关系。
然后从个体状态集合 Sꚍ 里提取在某个时刻 ꚍ 的切片 S 进行处理。它们在多目关注模块中再次处理。
这两个操作重复 Lenc 次,从而获得维度 {dK, M, t}的上下文张量 C,它汇总了原始数据的整个场景表述,其中 t 是源数据场景中的时间步数。
解码器的目标是据多模数据分布的上下文生成在时序和社团上一致的预测。为了生成c 个不同的预测,或原始数据相同的场景,AutoBot 解码器使用可训练初始参数 Qi 的 c 个矩阵,其维度为 {dK, T},其中 T 是计划范围。
直观地说,每个可训练初始参数矩阵都对应于 AutoBot 中离散潜变量的设置。然后,每个可训练矩阵 Qi 沿个体维度重复 M 次,以便获得维度为 {dK, M, T} 的输入张量 Q0i。
该算法提供了使用额外上下文信息的能力,其由卷积神经网络进行编码,从而创建特征向量 mi。为了向所有未来的时间步骤,和集合的所有元素提供上下文信息,提议沿维度 M 和 T 复制该向量,创建一个维度为 {dK, M, T} 的张量 Mi。然后,每个张量 Q0i 沿维度 Mi 与 Mi 结合。然后使用全连接层(rFFN)处理该张量,从而获得维度 {dK, M, T} 的张量 H。
解码首先处理在编码器输出(C)处判定的时间维度,以及编码的初始参数,和有关环境的信息(H)。解码器使用多头关注模块分别处理 H 中的每位个体。因此,我们获得了一个张量,它能为集合中每个元素的未来时间演变独立编码。
为了确保集合元素之间未来场景的社团一致性,我们处理每个时间片 H0,提取未来某个时间点 ꚍ. 的个体状态集合 H0ꚍ。序列的每个元素都由一个多目关注单元处理。该模块在集合的所有元素之间的每个时间步执行关注。
这两个操作重复 Ldec 次,从而为个体 i 创建最终输出张量。解码过程重复 c 次,并采用不同的训练初始参数 Qi,和额外的上下文信息 mi。解码器的输出是维度为 {dK, M, T, c} 的张量 O,然后可以使用神经网络 ф(.) 对其进行处理,从而获得所需的输出表述。
与其它方法相比,AutoBot 得出结果和训练时间更快的主要贡献之一是使用初始解码器参数 Qr。这些选项具有双重目的。首先,它们在预测未来时考虑了多样性,其中每个矩阵 Qi 对应于离散潜变量的设置之一。其次,它们允许 AutoBot 通过解码器进行一次验算,而无需顺序选择,从而帮助加快 AutoBot 的速度。
由论文作者演绎的方法的原始可视化提供如下。
2. 利用 MQL5 实现
我们已经讨论过“潜变量顺序集合转换器”(AutoBots)方法的理论方面。现在我们转到本文的实践部分,其中我们将利用 MQL5 实现我们所阐述方法的愿景。
起初,您应该注意以下 2 点。
首先,该方法提供位置编码。然而,我们已经看到在基本的自关注方法中使用了类似的位置编码。但事实是,早些时候,在研究关注方法时,源数据的位置编码是在主程序一侧实现的。不过,在 AutoBot 中,位置编码是在对源数据进行初步处理,且创建嵌入后在模型中实现的。当然,我们可以将数据预处理移到单独的模型当中,并在将数据传输到编码器之前在主程序一侧实现位置编码。但该选项需要在 OpenCL 关联环境的内存和主程序之间执行额外的数据传输操作。此外,如此实现会在未额外调整代码的单个程序中限制我们运用各种模型架构的灵活性。因此,更可取的方式是在一个模型内组织整个过程。
其次,在编码器和解码器两者当中,“潜变量顺序集合转换器”(AutoBots)方法都需要在分析张量的各个维度(时间和社团依赖性分析)的框架内交替用到关注模块。若要改变关注焦点的维度,我们需要修改多目关注层 CNeuronMLMHAttentionOCL、或转置张量。于此,转置张量看似是一项较简单的任务。这需要前面讨论过的位置编码的某些步骤。我们不会在此重复它们。我们只需在 OpenCL 关联环境端创建一个张量转置层。
2.1位置编码层
我们将从位置编码层开始。我们从 CNeuronBaseOCL 函数库的神经层基类继承位置编码层类 CNeuronPositionEncoder,并覆盖基本方法集:
- Init — 初始化
- feedForward — 前馈验算
- calcInputGradients — 误差梯度传播到前一层
- updateInputWeights — 更新权重
- Save 和 Load — 文件操作
class CNeuronPositionEncoder : public CNeuronBaseOCL { protected: CBufferFloat PositionEncoder; virtual bool feedForward(CNeuronBaseOCL *NeuronOCL); virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL) { return true; } public: CNeuronPositionEncoder(void) {}; ~CNeuronPositionEncoder(void) {}; //--- virtual bool Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint count, uint window, ENUM_OPTIMIZATION optimization_type, uint batch); virtual bool calcInputGradients(CNeuronBaseOCL *NeuronOCL) { return true; } //--- virtual bool Save(int const file_handle); virtual bool Load(int const file_handle); //--- virtual int Type(void) const { return defNeuronPEOCL; } virtual void SetOpenCL(COpenCLMy *obj); };
我们将类的构造函数和析构函数留空。
在我们继续讨论其它方法之前,我们先讨论一下类的功能和构造逻辑。在转换器算法中,位置编码是调用以下函数向源数据添加正弦谐波来实现的:
请注意,在这种情况下,我们针对分析序列中的元素执行位置编码。它与之前所用的时间戳谐波无关,即我们在主程序一侧创建时间戳谐波。过程相似,但含义不同。
显然,模型中所分析序列的大小将始终保持不变。因此,我们可以简单地在类初始化方法 Init 中创建并填充谐波缓冲区 PositionEncoder。在前馈验算期间,在 feedForward 方法中,我们只将谐波值添加到原始数据之中。
这与前馈验算有关。反向传递验算呢?在前馈验算中,我们执行了两个张量的加法。因此,反向传播验算期间的误差梯度均匀分布或完全转移到两项。在我们的例子中,位置编码的谐波张量是一个常数。因此,我们将整个误差梯度转移到前一层。
至于可训练的权重,它们根本不存在于位置编码层之中。因此,重写 updateInputWeights 方法仅出于类兼容性,且始终返回 true。
逻辑就是这样。现在我们来看实现。该类在 Init 方法中初始化。该方法在参数里接收:
- numOutputs — 到下一层的连接数
- open_cl — 指向 OpenCL 关联环境的指针
- count — 序列中的元素数
- window — 序列中每个元素的参数数量
- optimization_type — 参数优化方法。
bool CNeuronPositionEncoder::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint count, uint window, ENUM_OPTIMIZATION optimization_type, uint batch) { if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, count * window, optimization_type, batch)) return false;
在方法的主体中,我们调用父类的初始化方法,其实现了基本功能。我们还会检查操作的结果。
接下来,我们需要创建位置编码谐波。我们将为此使用矩阵运算。首先,我们准备矩阵。
matrix<float> pe = matrix<float>::Zeros(count, window);
我们创建一个向量来对张量中元素的位置进行编号,并创建一个用于所有元素的常数因子。
vector<float> position = vector<float>::Ones(count); position = position.CumSum() - 1; float multipl = -MathLog(10000.0f) / window;
由于根据位置编码,我们需要交替谐波的正弦和余弦公式,故我们将在填充矩阵的循环中取步长为 2。在循环的主体中,我们首先计算位置值的向量。然后,在偶数列中,我们添加位置值向量的正弦值。在奇数列中,我们写入同一向量的余弦值。
for(uint i = 0; i < window; i += 2) { vector<float> temp = position * MathExp(i * multipl); pe.Col(MathSin(temp), i); if((i + 1) < window) pe.Col(MathCos(temp), i + 1); }
我们将位置谐波的结果复制到数据缓冲区之中,并将其传输到 OpenCL 关联环境。
if(!PositionEncoder.AssignArray(pe)) return false; //--- return PositionEncoder.BufferCreate(open_cl); }
在 CNeuronPositionEncoder 之后,我们转到方法 feedForward 中组织前馈验算。您也许已经注意到,我们并未在 OpenCL 关联环境端创建一个过程来组织内核。我们直接进入该方法的实现。这是因为添加 2 个矩阵的内核 SumMatrix 在我们实现自关注方法时就已创建了。
如常,feedForward 方法在参数中接收指向前一个神经层的指针,其用作源数据。在方法的主体中,我们检查所接收指针。
bool CNeuronPositionEncoder::feedForward(CNeuronBaseOCL *NeuronOCL) { if(!NeuronOCL) return false; if(!Gradient || Gradient != NeuronOCL.getGradient()) { if(!!Gradient) delete Gradient; Gradient = NeuronOCL.getGradient(); }
我们还立即替换指向误差梯度缓冲区的指针。这种简单的方法将允许我们在反向传播验算过程中直接将误差梯度从下一层转移到前一层,从而消除了位置编码层中不必要的数据复制。
接下来,我们将必要的数据传递给向量加法内核的参数。
uint global_work_offset[1] = {0}; uint global_work_size[1]; global_work_size[0] = Neurons(); if(!OpenCL.SetArgumentBuffer(def_k_MatrixSum, def_k_sum_matrix1, NeuronOCL.getOutputIndex())) return false; if(!OpenCL.SetArgumentBuffer(def_k_MatrixSum, def_k_sum_matrix2, PositionEncoder.GetIndex())) return false; if(!OpenCL.SetArgumentBuffer(def_k_MatrixSum, def_k_sum_matrix_out, Output.GetIndex())) return false; if(!OpenCL.SetArgument(def_k_MatrixSum, def_k_sum_dimension, (int)1)) return false; if(!OpenCL.SetArgument(def_k_MatrixSum, def_k_sum_multiplyer, 1.0f)) return false;
将内核放入执行队列当中。
if(!OpenCL.Execute(def_k_MatrixSum, 1, global_work_offset, global_work_size)) { printf("Error of execution kernel MatrixSum: %d", GetLastError()); return false; } //--- return true; }
检查操作结果。这样,就可以认为前馈过程的实现已完成。
如上所述,位置编码层不包含可训练的参数。因此,updateInputWeights 方法是“空的”,且始终返回 true。通过替换误差梯度缓冲指针,我们从误差梯度传播过程中完全消除了位置编码层。因此,calcInputGradients 方法与参数更新方法一样,保持“空的”,且仅出于兼容性目的而被覆盖。
我们对位置编码层方法的讨论到此结束。该类的完整代码可在附件 “...\Experts\NeuroNet_DNG\NeuroNet.mqh” 中找到,其中包含我们函数库的所有类。
2.2转置张量
我们同意创建的下一层是 CNeuronTransposeOCL 张量转置层。与位置编码层一样,在创建类时,我们从 CNeuronBaseOCL 神经层基类继承。重写的类清单仍然是标准的。不过,我们还要添加 2 个变量类来存储转置矩阵的维度。
class CNeuronTransposeOCL : public CNeuronBaseOCL { protected: uint iWindow; uint iCount; //--- virtual bool feedForward(CNeuronBaseOCL *NeuronOCL); virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL) { return true; } public: CNeuronTransposeOCL(void) {}; ~CNeuronTransposeOCL(void) {}; //--- virtual bool Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint count, uint window, ENUM_OPTIMIZATION optimization_type, uint batch); virtual bool calcInputGradients(CNeuronBaseOCL *NeuronOCL); //--- virtual bool Save(int const file_handle); virtual bool Load(int const file_handle); //--- virtual int Type(void) const { return defNeuronTransposeOCL; } };
类构造和析构保持为空。类的初始化方法 Init 非常简单。在方法主体中,我们只调用父类的相关方法,并保存参数中获取的转置矩阵的维度。不要忘记检查操作结果。
bool CNeuronTransposeOCL::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint count, uint window, ENUM_OPTIMIZATION optimization_type, uint batch) { if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, count * window, optimization_type, batch)) return false; //--- iWindow = window; iCount = count; //--- return true; }
对于前馈方法,我们首先必须创建一个矩阵转置张量 Transpose。在内核参数中,我们只传递指向源数据和结果矩阵缓冲区的指针。我们从二维问题空间获得矩阵的大小。
__kernel void Transpose(__global float *matrix_in, ///<[in] Input matrix __global float *matrix_out ///<[out] Output matrix ) { const int r = get_global_id(0); const int c = get_global_id(1); const int rows = get_global_size(0); const int cols = get_global_size(1); //--- matrix_out[c * rows + r] = matrix_in[r * cols + c]; }
内核算法十分简单。我们仅需判定元素在源数据矩阵和结果矩阵中的位置。之后我们转移数值。
内核是在前馈验算方法 feedForward 调用的。内核调用算法与上面示意的算法类似。我们首先定义问题空间,但这次是在二维空间(序列中的元素数 * 序列中每个元素中的特征数)。然后我们将指向数据缓冲区的指针传递给内核参数,并将其放入执行队列当中。不要忘记检查操作结果。
bool CNeuronTransposeOCL::feedForward(CNeuronBaseOCL *NeuronOCL) { if(!NeuronOCL) return false; //--- uint global_work_offset[2] = {0, 0}; uint global_work_size[2] = {iCount, iWindow}; if(!OpenCL.SetArgumentBuffer(def_k_Transpose, def_k_tr_matrix_in, NeuronOCL.getOutputIndex())) return false; if(!OpenCL.SetArgumentBuffer(def_k_Transpose, def_k_tr_matrix_out, Output.GetIndex())) return false; if(!OpenCL.Execute(def_k_Transpose, 2, global_work_offset, global_work_size)) { string error; CLGetInfoString(OpenCL.GetContext(), CL_ERROR_DESCRIPTION, error); printf("Error of execution kernel Transpose: %d -> %s", GetLastError(), error); return false; } //--- return true; }
在反向传播验算期间,我们需要将误差梯度传播到对立的方向。我们还需要转置误差梯度矩阵。因此,我们将使用相同的内核。我们仅需逆转问题空间的维度,并指定指向误差梯度缓冲区的指针。
bool CNeuronTransposeOCL::calcInputGradients(CNeuronBaseOCL *NeuronOCL) { if(!NeuronOCL) return false; //--- uint global_work_offset[2] = {0, 0}; uint global_work_size[2] = {iWindow, iCount}; if(!OpenCL.SetArgumentBuffer(def_k_Transpose, def_k_tr_matrix_out, NeuronOCL.getGradientIndex())) return false; if(!OpenCL.SetArgumentBuffer(def_k_Transpose, def_k_tr_matrix_in, Gradient.GetIndex())) return false; if(!OpenCL.Execute(def_k_Transpose, 2, global_work_offset, global_work_size)) { string error; CLGetInfoString(OpenCL.GetContext(), CL_ERROR_DESCRIPTION, error); printf("Error of execution kernel Transpose: %d -> %s", GetLastError(), error); return false; } //--- return true; }
如您所见,CNeuronTransposeOCL 类不包含可训练的参数,因此 updateInputWeights 方法始终返回 true。
2.3AutoBot 的架构
上面我们创建了 2 个新的通用层。现在我们可以直接实现 “潜变量顺序集合转换器”(AutoBots)方法了。首先,我们将在 CreateTrajNetDescriptions 方法中创建价格走势预测模型的架构。为了减少主程序一侧的操作,我决定在一个模型的框架内组织 AutoBot 操作。为了描述它,将一个指向动态数组的指针传递给该方法。在方法的主体中,我们检查收到的指针,并在必要时创建动态数组对象的新实例。
bool CreateTrajNetDescriptions(CArrayObj *autobot) { //--- CLayerDescription *descr; //--- if(!autobot) { autobot = new CArrayObj(); if(!autobot) return false; }
该模型由原始数据的张量馈送。如前,为了在模型运行和训练期间优化计算,我们将仅采用最后一根柱线的描述作为初始数据。整个历史记录累积在 Embedding 层缓冲区内。
//--- Encoder autobot.Clear(); //--- 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(!autobot.Add(descr)) { delete descr; return false; }
源数据的初级处理在批量规范化层中实现。
//--- layer 1 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBatchNormOCL; descr.count = prev_count; descr.batch = MathMax(1000,GPTBars); descr.activation = None; descr.optimization = ADAM; if(!autobot.Add(descr)) { delete descr; return false; }
之后,我们生成一个状态嵌入,并将其添加到历史数据缓冲区之中。
//--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronEmbeddingOCL; { int temp[] = {prev_count}; ArrayCopy(descr.windows, temp); } prev_count = descr.count = GPTBars; int prev_wout = descr.window_out = EmbeddingSize; if(!autobot.Add(descr)) { delete descr; return false; }
请注意,在本例中,我们只嵌入了一个描述环境当前状态的实体。该层的功能接近全连接层。不过,我们使用 CNeuronEmbeddingOCL 层,因为我们需要创建一个缓冲区,是为嵌入的历史序列。不过,该算法对金融产品柱线的分析没有设置任何约束。我们可以分析多根烛条和多个交易中的金融产品。但在这种情况下,您需要调整嵌入的数组。
接下来,我们将位置编码张量添加到整个历史嵌入序列。
//--- layer 3 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronPEOCL; descr.count = prev_count; descr.window = prev_wout; if(!autobot.Add(descr)) { delete descr; return false; }
我们执行第一个关注模块来评测场景之间的时间依赖关系。
//--- layer 4 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronMLMHAttentionOCL; descr.count = prev_count; descr.window = prev_wout; descr.step = 4; descr.window_out = 16; descr.layers = 1; descr.optimization = ADAM; if(!autobot.Add(descr)) { delete descr; return false; }
然后,我们需要分析独立特征之间的依赖关系。为此,我们转置张量,并将关注模块应用于转置的张量。
//--- layer 5 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronTransposeOCL; descr.count = prev_count; descr.window = prev_wout; if(!autobot.Add(descr)) { delete descr; return false; } //--- layer 6 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronMLMHAttentionOCL; descr.count = prev_wout; descr.window = prev_count; descr.step = 4; descr.window_out = 16; descr.layers = 1; descr.optimization = ADAM; if(!autobot.Add(descr)) { delete descr; return false; }
请注意,转置后,我们还会更改关注模块中的维度,令其与转置的张量相对应。
我们再次转置张量,从而将其返回到其原始维度。然后我们再次重复编码器的关注模块。
//--- layer 7 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronTransposeOCL; descr.count = prev_wout; descr.window = prev_count; if(!autobot.Add(descr)) { delete descr; return false; } //--- layer 8 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronMLMHAttentionOCL; descr.count = prev_count; descr.window = prev_wout; descr.step = 4; descr.window_out = 16; descr.layers = 1; descr.optimization = ADAM; if(!autobot.Add(descr)) { delete descr; return false; } //--- layer 9 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronTransposeOCL; descr.count = prev_count; descr.window = prev_wout; if(!autobot.Add(descr)) { delete descr; return false; } //--- layer 10 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronMLMHAttentionOCL; descr.count = prev_wout; descr.window = prev_count; descr.step = 4; descr.window_out = 16; descr.layers = 1; descr.optimization = ADAM; if(!autobot.Add(descr)) { delete descr; return false; }
在编码器的输出端,我们收到一个描述环境当前状态的上下文。我们需要将其传输到解码器,以便预测价格走势的未来参数,并至所需的规划深度。不过,根据 “潜变量顺序集合转换器” 算法,在该阶段我们需要添加可训练的初始参数 Q。但在我们的函数库当前实现中,可训练参数仅包括神经层的权重。为了不令现有流程复杂化,我采用了一种也许不标准、但有效的解决方案。在这种情况下,我们将使用 СNeuronConcatenate 张量连接层。该层的第一部分将替换完全连接层,以便更改从编码器接收的当前环境状态的上下文表述。第二个模块的权重将充当初始可训练参数 Q。为了不扭曲 Q 参数的值,我们将一个填充 1 的向量馈送到第二个输入。
在该层的输出中,我们希望收到给定规划深度的状态嵌入张量。
//--- Decoder //--- layer 11 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConcatenate; descr.count = PrecoderBars * EmbeddingSize; descr.window = prev_count * prev_wout; descr.step = EmbeddingSize; descr.activation = LReLU; descr.optimization = ADAM; if(!autobot.Add(descr)) { delete descr; return false; }
与编码器一样,我们首先查看状态之间随时间变化的依赖关系。
//--- layer 12 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronMLMHAttentionOCL; prev_count = descr.count = PrecoderBars; prev_wout = descr.window = EmbeddingSize; descr.step = 4; descr.window_out = 16; descr.layers = 1; descr.optimization = ADAM; if(!autobot.Add(descr)) { delete descr; return false; }
然后,我们转置张量,并分析独立特征之间的上下文依赖关系。
//--- layer 13 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronTransposeOCL; descr.count = prev_count; descr.window = prev_wout; if(!autobot.Add(descr)) { delete descr; return false; } //--- layer 14 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronMLMHAttentionOCL; descr.count = prev_wout; descr.window = prev_count; descr.step = 4; descr.window_out = 16; descr.layers = 1; descr.optimization = ADAM; if(!autobot.Add(descr)) { delete descr; return false; }
之后,我们再次重复解码器操作。
//--- layer 15 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConcatenate; descr.count = prev_count * prev_wout; descr.window = descr.count; descr.step = EmbeddingSize; descr.activation = LReLU; descr.optimization = ADAM; if(!autobot.Add(descr)) { delete descr; return false; } //--- layer 16 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronMLMHAttentionOCL; descr.count = prev_count; descr.window = prev_wout; descr.step = 4; descr.window_out = 16; descr.layers = 1; descr.optimization = ADAM; if(!autobot.Add(descr)) { delete descr; return false; } //--- layer 17 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronTransposeOCL; descr.count = prev_count; descr.window = prev_wout; if(!autobot.Add(descr)) { delete descr; return false; } //--- layer 18 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronMLMHAttentionOCL; descr.count = prev_wout; descr.window = prev_count; descr.step = 4; descr.window_out = 16; descr.layers = 1; descr.optimization = ADAM; if(!autobot.Add(descr)) { delete descr; return false; }
注意,使用第一个常数向量作为模型的第二个输入,令我们可以在解码器里多次迭代连接层。在这种情况下,可训练权重参数扮演每层唯一的 Q 参数的角色。
为了完成解码器,我们用到一个全连接层,它允许我们按所需的格式呈现数据。
//--- layer 19 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = PrecoderBars * 3; descr.activation = None; descr.optimization = ADAM; if(!autobot.Add(descr)) { delete descr; return false; } //--- return true; }
2.4训练 AutoBot
我们已经讨论了 AutoBot 模型的架构,在给定的计划深度下预测即将到来的价格走势的参数。至于已训练模型的结果如何运用,仅受限于您的想象力。在预测了后续的价格走势后,您可以构建一个经典算法 EA,并根据收到的预测执行操作。可选地,您可以将其传递给扮演者模型,从而直接生成动作建议。我采用了第二个选项。在这种情况下,扮演者模型的架构和目标设置是从之前的文章中借来的。这些变化仅影响源数据层,以便匹配上述 AutoBot 模型的结果。我们现在不会详述它们。它们附在下面(CreateDescriptions 方法) ,故您可以自行研究它们。在那里,您还可以熟悉自行具体调整 EA 与环境的交互 “...\Experts\AutoBots\Research.mq5”。我们继续规划模型训练过程,从而预测即将到来的价格走势。训练过程在 EA “...\Experts\AutoBots\StudyTraj.mq5” 中实现。
在该 EA 中,我们只训练一个模型。
CNet Autobot;
在 EA 初始化方法 OnInit 中,我们首先加载训练数据集。
int OnInit() { //--- ResetLastError(); if(!LoadTotalBase()) { PrintFormat("Error of load study data: %d", GetLastError()); return INIT_FAILED; }
然后,我们尝试加载预先训练的 AutoBot 模型,如果发生错误,我们将创建一个新模型,并按随机参数初始化。
//--- load models float temp; if(!Autobot.Load(FileName + "Traj.nnw", temp, temp, temp, dtStudied, true)) { Print("Init new models"); CArrayObj *autobot = new CArrayObj(); if(!CreateTrajNetDescriptions(autobot)) { delete autobot; return INIT_FAILED; } if(!Autobot.Create(autobot)) { delete autobot; return INIT_FAILED; } delete autobot; //--- }
此后,我们检查模型架构是否符合主要准则。
Autobot.getResults(Result); if(Result.Total() != PrecoderBars * 3) { PrintFormat("The scope of the Autobot does not match the precoder bars (%d <> %d)", PrecoderBars * 3, Result.Total()); return INIT_FAILED; } //--- Autobot.GetLayerOutput(0, Result); if(Result.Total() != (HistoryBars * BarDescr)) { PrintFormat("Input size of Autobot doesn't match state description (%d <> %d)", Result.Total(), (HistoryBars * BarDescr)); return INIT_FAILED; }
我们创建必要的数据缓冲区。
OpenCL = Autobot.GetOpenCL(); if(!Ones.BufferInit(EmbeddingSize, 1) || !Gradient.BufferInit(EmbeddingSize, 0) || !Ones.BufferCreate(OpenCL) || !Gradient.BufferCreate(OpenCL)) { PrintFormat("Error of create buffers: %d", GetLastError()); return INIT_FAILED; } State.BufferInit(HistoryBars * BarDescr, 0);
为开始模型训练,我们生成一个自定义事件。
if(!EventChartCustom(ChartID(), 1, 0, 0, "Init")) { PrintFormat("Error of create study event: %d", GetLastError()); return INIT_FAILED; } //--- return(INIT_SUCCEEDED); }
在 EA 逆初始化方法中,我们保存经过训练的模型,并从内存中删除动态对象。
void OnDeinit(const int reason) { //--- if(!(reason == REASON_INITFAILED || reason == REASON_RECOMPILE)) Autobot.Save(FileName + "Traj.nnw", 0, 0, 0, TimeCurrent(), true); delete Result; delete OpenCL; }
如常,模型训练过程在 Train 方法中实现。在该方法的主体中,我们首先基于其盈利能力检测选择轨迹的概率。
void Train(void) { //--- vector<float> probability = GetProbTrajectories(Buffer, 0.9);
然后我们声明并初始化局部变化。
vector<float> result, target, inp; matrix<float> targets; matrix<float> delta; STE = vector<float>::Zeros(PrecoderBars * 3); int std_count = 0; int batch = GPTBars + 50; bool Stop = false; uint ticks = GetTickCount(); ulong size = HistoryBars * BarDescr;
如常,在训练轨迹模型时,我们仅限于"潜变量顺序集合转换器"方法的作者提议的方式。特别是,我们将把训练的重点放在最大偏差上,就如 CFPI 方法一般。此外,为了确保模型在随机市场中的稳定性,我们将通过向原始数据添加噪声来 “扩展” 训练样本空间,如 SSWNP 方法中所提议的那样。为了实现这些方式,我们将在局部变量中声明一个参数变化矩阵 delta,以及一个均方误差向量 STE。
但是,我们回到我们方法的算法。在我们的轨迹预测 AutoBot 的架构中,我们用到了一个带有内置缓冲区的嵌入层来积累历史数据,这令我们能够在模型操作期间不必重新计算重复数据的表示。不过,这种方式还要求在学习过程中提交初始数据时遵守历史一致性。因此,我们将使用嵌套循环系统来训练模型。外部循环判定训练迭代的次数。
for(int iter = 0; (iter < Iterations && !IsStopped() && !Stop); iter ++) { int tr = SampleTrajectory(probability); int state = (int)((MathRand() * MathRand() / MathPow(32767, 2)) * (Buffer[tr].Total - 3 - PrecoderBars - batch)); if(state < 0) { iter--; continue; }
在循环主体中,我们从缓冲区中进行轨迹取样,同时参考之前计算的概率。然后我们基于所选轨迹随机检测学习的初始状态。
我们还检测训练包的最终状态。我们清除 Autobot 的历史缓冲区。并准备一个矩阵,记录参数变化。
int end = MathMin(state + batch, Buffer[tr].Total - PrecoderBars); Autobot.Clear(); delta = matrix<float>::Zeros(end - state - 1, Buffer[tr].States[state].state.Size());
接下来,我们创建一个嵌套循环来处理干净轨迹,在它的主体中填充源数据缓冲区。
for(int i = state; i < end; i++) { inp.Assign(Buffer[tr].States[i].state); State.AssignArray(inp);
我们计算后续 2 个环境状态之间的参数值偏差。
if(i < (end - 1)) delta.Row(inp, row); if(row > 0) delta.Row(delta.Row(row - 1) - inp, row - 1);
准备工作结束后,我们对模型执行前向验算。
if(!Autobot.feedForward((CBufferFloat*)GetPointer(State), 1, false, (CBufferFloat*)GetPointer(Ones))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
请注意,我们使用填充了常量值 1 的缓冲区作为第二个源数据流,如描述模型架构时所讨论的那样。该缓冲区是在 EA 初始化期间准备好的,在模型的整个训练过程中不会更改。
前馈验算之后是更新模型参数的反向传播验算。但在调用之前,我们需要先准备目标值。为此,我们来 “展望未来”。在训练过程中,该能力由训练数据集提供。从经验回放缓冲区中,我们提取了给定规划深度的后续环境状态的描述。将必要的数据复制到目标值向量 target 之中。
targets = matrix<float>::Zeros(PrecoderBars, 3); for(int t = 0; t < PrecoderBars; t++) { target.Assign(Buffer[tr].States[i + 1 + t].state); if(size > BarDescr) { matrix<float> temp(1, size); temp.Row(target, 0); temp.Reshape(size / BarDescr, BarDescr); temp.Resize(size / BarDescr, 3); target = temp.Row(temp.Rows() - 1); } targets.Row(target, t); } targets.Reshape(1, targets.Rows()*targets.Cols()); target = targets.Row(0);
然后,我们加载 Autobot 的前馈验算结果,并根据当前状态下预测误差的大小判定是否需要反向传播验算。
Autobot.getResults(result); vector<float> error = target - result; std_count = MathMin(std_count, 999); STE = MathSqrt((MathPow(STE, 2) * std_count + MathPow(error, 2)) / (std_count + 1)); std_count++; vector<float> check = MathAbs(error) - STE * STE_Multiplier;
如果存在至少有一个参数的预测误差高于阈值,则执行反向传播验算,该阈值与模型的均方根预测误差有关。
if(check.Max() > 0) { //--- Result.AssignArray(target); if(!Autobot.backProp(Result, GetPointer(Ones), GetPointer(Gradient))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; } }
接下来,我们通知用户训练过程的进度,并转到下一次迭代,处理干净轨迹批次。
if(GetTickCount() - ticks > 500) { double percent = (double(i - state) / (2 * (end - state)) + iter) * 100.0 / (Iterations); string str = StringFormat("%-20s %6.2f%% -> Error %15.8f\n", "Autobot", percent, Autobot.getRecentAverageError()); Comment(str); ticks = GetTickCount(); } }
完成干净轨迹训练批次后,我们转到第二个模块 — 基于加噪数据的轨迹模型。此处,我们首先定义噪声重新参数化参数。
//--- With noise vector<float> std_delta = delta.Std(0) * STD_Delta_Multiplier; vector<float> mean_delta = delta.Mean(0);
并准备一个数组,和一个向量来操控噪声。
ulong inp_total = std_delta.Size(); vector<float> noise = vector<float>::Zeros(inp_total); double ar_noise[];
我们还从训练数据集中对轨迹进行取样,据其检测训练批次的初始和最终状态,并清除模型的历史缓冲区。
tr = SampleTrajectory(probability); state = (int)((MathRand() * MathRand() / MathPow(32767, 2)) * (Buffer[tr].Total - 3 - PrecoderBars - batch)); if(state < 0) { iter--; continue; } end = MathMin(state + batch, Buffer[tr].Total - PrecoderBars); Autobot.Clear();
然后我们创建第二个嵌套循环。
for(int i = state; i < end; i++) { if(!Math::MathRandomNormal(0, 1, (int)inp_total, ar_noise)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; } noise.Assign(ar_noise); noise = mean_delta + std_delta * noise;
在循环主体中,我们生成噪声,并采用上面计算的分布参数对其进行重新参数化。
我们把生成的噪声添加到原始数据当中,并执行模型的前馈验算。
inp.Assign(Buffer[tr].States[i].state); inp = inp + noise; State.AssignArray(inp); //--- if(!Autobot.feedForward((CBufferFloat*)GetPointer(State), 1, false, (CBufferFloat*)GetPointer(Ones))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
我们完全复制执行反向传播验算的算法,包括从干净轨迹的操作模块里准备目标数据,以及检测其所需。
targets = matrix<float>::Zeros(PrecoderBars, 3); for(int t = 0; t < PrecoderBars; t++) { target.Assign(Buffer[tr].States[i + 1 + t].state); if(size > BarDescr) { matrix<float> temp(1, size); temp.Row(target, 0); temp.Reshape(size / BarDescr, BarDescr); temp.Resize(size / BarDescr, 3); target = temp.Row(temp.Rows() - 1); } targets.Row(target, t); } targets.Reshape(1, targets.Rows()*targets.Cols()); target = targets.Row(0); Autobot.getResults(result); vector<float> error = target - result; std_count = MathMin(std_count, 999); STE = MathSqrt((MathPow(STE, 2) * std_count + MathPow(error, 2)) / (std_count + 1)); std_count++; vector<float> check = MathAbs(error) - STE * STE_Multiplier; if(check.Max() > 0) { //--- Result.AssignArray(target); if(!Autobot.backProp(Result, GetPointer(Ones), GetPointer(Gradient))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; } }
最后,我们只需通告用户训练进度,并转入下一次训练迭代。
if(GetTickCount() - ticks > 500) { double percent = (double(i - state) / (2 * (end - state)) + iter + 0.5) * 100.0 / (Iterations); string str = StringFormat("%-20s %6.2f%% -> Error %15.8f\n", "Autobot", percent, Autobot.getRecentAverageError()); Comment(str); ticks = GetTickCount(); } } }
模型训练的循环系统所有迭代完成后,我们清除图表上的注释字段。将训练结果打印到日志中,并结束 EA 操作。
Comment(""); //--- PrintFormat("%s -> %d -> %-20s %10.7f", __FUNCTION__, __LINE__, "Autobot", Autobot.getRecentAverageError()); ExpertRemove(); //--- }
我们已经完成了轨迹训练模型智能系统方法 “...\Experts\AutoBots\StudyTraj.mq5” 的研究。该 EA 的完整代码附在文后。附件还包括扮演者政策训练 “...\Experts\AutoBots\Study.mq5”,以及已训练模型依据历史数据测试 “...\Experts\AutoBots\Test.mq5”。在这些 EA 中,我们只研究了与 AutoBot 模型操作有关的某些变化。我们现在进入测试阶段。
3. 测试
我们已经做完了相当广泛的工作,利用 MQL5 实现潜变量顺序集合转换器(AutoBots)方法的方式。现在是时候评估结果了。如之前的所有情况一样,我们的模型采用 2023 年前 7 个月的 EURUSD H1 数据进行训练。为了测试扮演者政策的已训练模型,我们采用 2023 年 8 月的历史数据。如您所见,测试期紧随训练期之后,这可确保训练和测试数据集的数据之间最大兼容性。
在训练和测试过程中,分析市场状况的所有指标的参数都没有得到优化。它们在使用时配以默认参数。
您也许已经注意到,我们从以前的工作复制了初始数据的元件和结构,以及轨迹预测模型的结果,且无变化。因此,为了训练模型,我们可以取用之前创建的样本数据库。这令我们能够避开训练数据的主要收集阶段,直接进入模型训练过程。
我们将分 2 个阶段训练模型:
- 训练轨迹预测模型
- 训练扮演者政策
轨迹预测模型仅看重市场动态,及所分析指标,而不参考账户状态和持仓,这为训练样本的轨迹增加了多样性。由于我们收集的是同一历史时期、同一金融产品的所有轨迹,因此在 AutoBot 的理解中,所有轨迹都是相同的。因此,我们可以在单个训练数据集上训练价格走势预测模型,而无需更新轨迹,直到获得可接受的结果。
事实证明,训练过程非常稳定,并展现出几乎恒定的误差降的良好动态。在此,我不得不同意该方法的作者在谈论模型学习速度时的观点。例如,该方法的作者声称,在他们的工作期间,所有模型在一个 1080 Ti 桌面图形加速器上训练了 48 小时。
受到训练价格走势预测模型过程的启发,我认为根据经过训练的扮演者政策的性能来评估轨迹预测算法并不完全正确。尽管扮演者政策基于收到的预测数据,但它会适配生成的预测中可能出现的误差。这种适配的品质是另一回事,它与扮演者的架构、及其训练过程有关。然而,这种适配肯定会产生影响。因此,我为经典算法交易创建了一个小型 EA “...\Experts\AutoBots\Alternate.mq5”。
创建 EA 只是为了测试在策略测试器中预测价格走势的品质,在我看来,其代码并不会引起太大的兴趣。因此,我们不会在本文中详述它。您可以在附件中自行学习其代码。
该 EA 评算预测走势,并在规划范围内沿强势方向以最小手数进行交易。EA 参数尚未优化。直到 2023 年底之前,在策略测试器中测试 EA 时获得了有趣的结果。
在 7 个月的历史数据上训练价格走势预测模型后,我们得到余额在 2 个月内增长的稳定趋势。
所有成交均以最小手数执行。这意味着获得的结果仅取决于轨迹规划的品质。
结束语
在本文中,我们领略了 “潜变量顺序集合转换器”(AutoBots)方法。该方法的作者提出的方式基于对上下文和时序间信息的联合分布进行建模,这为准确(尽可能准确)预测未来价格走势提供了可靠的工具。
AutoBots 利用编码器-解码器架构,通过使用多功能关注模块,以及引入离散潜变量来模拟多模态分布,来展现高效操作。
在本文的实践部分,我们利用 MQL5 实现了所提议方法,并在模型学习速度和预测品质方面获得了可喜的结果。
因此,提出的 AutoBots 算法为解决外汇市场的预测问题提供了一个很有前途的工具,提供准确性、对变化的稳健性、以及建模多模态分布的能力,从而能更深入地了解市场走势动态。
参考
文中所用程序
# | 已发行 | 类型 | 说明 |
---|---|---|---|
1 | Research.mq5 | 智能交易系统 | 样本收集 EA |
2 | ResearchRealORL.mq5 | 智能交易系统 | 用于使用 Real-ORL 方法收集示例的 EA |
3 | Study.mq5 | 智能交易系统 | 政策训练 EA |
4 | StudyTraj.mq5 | 智能交易系统 | 轨迹预测模型训练 EA |
5 | Test.mq5 | 智能交易系统 | 模型测试 EA |
6 | Trajectory.mqh | 类库 | 系统状态定义结构 |
7 | NeuroNet.mqh | 类库 | 创建神经网络的类库 |
8 | NeuroNet.cl | 代码库 | OpenCL 程序代码库 |
9 | Alternate.mq5 | 智能交易系统 | 轨迹预测品质测试 EA |
本文由MetaQuotes Ltd译自俄文
原文地址: https://www.mql5.com/ru/articles/14095


