
神经网络变得简单(第 86 部分):U-形变换器
概述
预测长期时间序列对于交易特别重要。变换器架构于 2017 年推出,在自然语言处理(NLP)、和计算机视觉(CV)领域展现出令人印象深刻的性能。自关注机制的运用可在涵盖较长时间的间隔内有效捕获依赖关系,从上下文中提取关键信息。当然,基于这种机制迅速就提出了大量不同算法,来解决与时间序列相关的问题。
然而,最近的研究表明,针对不同时间序列数据集上的准确性,简单的多层感知器网络(MLP)能够超过基于变换器的模型。无论如何,变换器架构已在若干个领域证明了它的有效性,甚至找到了实际应用。因此,它的代表能力应该相对较强。必须有运用它的机制。改进原版变换器算法的选项之一是论文《U-形变换器:在时间序列分析中保留高频上下文》,其中阐述了 U-形变换器算法。
1. 算法
或许应当在一开始就说,U-形变换器方法的作者运作了一项全面的工作,不仅提议了优化经典变换器架构的途径,还有训练模型的方式。
我们已经看到,基于变换器架构的训练模型需要大量的计算资源和大量的训练样本。因此,在解决 NLP 和 CV 问题时,各种预训练模型被广泛使用。不幸的是,在解决时间序列问题时,我们没有这个机会,因为时间序列的性质和结构非常多样化。明白了这一点,U-形变换器方法的作者提议将模型训练过程分为 2 个阶段。
首先,提议使用一个相对较大的数据集来训练 U-形变换器模型,以便恢复随机掩码的输入数据。这将允许模型学习输入数据的结构、依赖关系、及时间序列的上下文。还有,这将启用对各种噪声的有效过滤。此外,在他们的论文中,该方法的作者用来自不同时间序列的数据补充了训练数据集,其不仅按不同的时间间隔收集,而且来自不同的来源。这就是他们如何打算训练 U-形变换器解决完全不同问题。
在第二阶段,U-形变换器的权重被冻结。一个决策制定“头”被加入其中。它经过优调,可在相对较小的训练数据集上解决特定问题。
这为使用一个预训练的 U-形变换器来解决若干个问题寻到了出路。
如您所见,与完整的 U-形变换器训练过程相比,优调要更快,且需要的资源更少。
以下是作者可视化的一般过程。
在 U-形变换器的核心是变换器层的堆叠。若干个变换器层形成一个组。处理分组之后,执行合并或切分修补的操作,以便集成不同尺度的特征。
多个跳接用于从编码器到解码器的快速数据传输。这允许高频数据快速接近神经网络的输出,而无需进行不必要的处理。变换器组的输入数据按相同的形状馈送到解码器输出中。输入数据编码期间,随着模型的下移,高频特征持续被过滤掉,而共同特征则被提取出来。在解码期间,会取来自跳连接的详细信息不断重建一般特征,按序列的时态表示的终极结果则结合了高频和低频特征两者。
修补操作是 U-形变换器模型的紧要组成部分,因为它们能够获得不同尺度的特征。所选特征直接影响在注意力计算的基础上下文中包含的信息。传统方式往往将时间序列拆分为双数组,并将它们视为独立的通道。U-形变换器方法的作者认为这种方式很粗略,因为在一个时间步中来自不同通道修补的相关信息并非源自相邻区域。因此,他们提议使用窗口大小和步幅为 2 的卷积作为修补池,这将令通道数量倍增。这可确保前一个修补不会碎片化,且结果按更好的尺度合并。在解码期间,该方法的作者相应地使用转置卷积作为修补分离操作。
U-形变换器方法使用带有点核的卷积作为嵌入方法,将每个修补映射到更高维的空间。然后,该方法的作者为每个修补准备一个可训练的相对位置编码器,将其添加到嵌入修补之中,以便强化修补之间先验知识的积累。
如上所述,U-形变换器方法作者使用基于修补的嵌入方式,将时间序列数据拆分为更小的区块。他们将修补恢复当作预任务。该方法作者相信,恢复未掩码修补可以提高模型应对噪声数据为零值的稳健性。
预训练之后,模型在特殊头单元上进行优调,其负责生成目标任务。在该阶段,它们会冻结除头网络之外的所有组件。
为了提高模型的普适能力,并在更大数据集上释放变换器的潜力,采用了更大的数据集。
为了进一步缓解数据不平衡,他们使用加权随机采样,其中训练期间来自不同数据集的样本数量是平衡的。
为了缓解基于变换器模型的训练不稳定性问题,该方法的作者提议对每个小批量进行归一化,从而令输入数据达到标准正态分布。
而结果,所提议数据预处理方法达成了从数据集不同部分提取有效特征,有效克服了在多个数据集上联合训练时数据不平衡的问题。
2. 利用 MQL5 实现
在研究了该方法的理论层面之后,我们转到本文的实践部分,其中我们利用 MQL5 实现了所提议的方式。
如上所述,U-形变换器运用了若干种我们必须实现的架构方案。我们从可训练位置编码模块开始。
2.1位置编码
位置编码模块设计用于把有关时间序列中元素位置的信息输入到时间序列当中。如您所知,自关注算法分析元素之间的依赖关系,而与它们在序列中的位置无关。但是,有关元素在序列中的位置、以及有关所分析元素之间距离的信息可能扮演重要角色。对于时间数据序列尤其如此。为了添加该信息,经典的变换器会往输入数据里添加正弦序列。正弦序列的周期性是固定的,并且在不同的实现中可能会有所不同,具体取决于所分析序列的大小。
时间序列的特征是存在一定的周期性。有时它们具有多种频率特性。在这种情况下,有必要执行额外的工作来选择位置编码张量的频率特性,令其不会扭曲原始数据、或向其中添加额外信息。
原始论文没有提供可训练位置编码方法的详细说明。我的印象是,该方法的作者在模型训练过程中会为序列的每个元素选择位置编码系数。因此,贯穿操作过程它都是固定的。
在我们的实现中,我们将更进一步,令位置编码系数取决于输入数据。我们将使用一个简单的全连接层来生成位置编码张量。当然,我们将在学习过程期间训练这一层。
为了实现所提议机制,我们将创建一个新类 CNeuronLearnabledPE。如同大多数情况,我们将从神经层基类 CNeuronBaseOCL 继承主要功能。
class CNeuronLearnabledPE : public CNeuronBaseOCL { protected: CNeuronBaseOCL cPositionEncoder; //--- virtual bool feedForward(CNeuronBaseOCL *NeuronOCL); virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL); virtual bool calcInputGradients(CNeuronBaseOCL *NeuronOCL); public: CNeuronLearnabledPE(void) {}; ~CNeuronLearnabledPE(void) {}; //--- virtual bool Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint numNeurons, ENUM_OPTIMIZATION optimization_type, uint batch); //--- virtual bool Save(int const file_handle); virtual bool Load(int const file_handle); //--- virtual int Type(void) const { return defNeuronLearnabledPE; } virtual void SetOpenCL(COpenCLMy *obj); };
类结构拥有神经网络基础层 cPositionEncoder 的一个嵌套对象,其中包含位置编码张量的可训练参数。这个对象被指定为静态,因此我们可以将类的构造器和析构器留空。
类实例在 Init 方法中初始化。在方法参数中,我们传递正确初始化嵌套对象所需的所有信息。
bool CNeuronLearnabledPE::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint numNeurons, ENUM_OPTIMIZATION optimization_type, uint batch) { if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, numNeurons, optimization_type, batch)) return false; if(!cPositionEncoder.Init(0, 1, OpenCL, numNeurons, optimization, iBatch)) return false; cPositionEncoder.SetActivationFunction(TANH); SetActivationFunction(None); //--- return true; }
该方法算法十分简单。在方法主体中,我们首先调用父类的相同方法,其检查接收到的外部参数,并初始化继承对象。我们通过调用方法执行后返回的逻辑值来判定操作的结果。
下一步是初始化嵌套的 cPositionEncoder 对象。对于嵌套对象,我们设置双曲正切为激活函数。该函数的值范围是从 “-1” 到 “1”,对应于正弦波序列的值范围。
至于我们的动态位置编码类,没有激活函数。
在所有迭代成功完成后,我们以 true 结果终止该方法。
我们在 CNeuronLearnabledPE::feedForward 方法中描述前馈验算算法。仿照父类的相同方法,该方法在参数中接收一个指向前一个神经层对象的指针,其中包含输入数据。
bool CNeuronLearnabledPE::feedForward(CNeuronBaseOCL *NeuronOCL) { if(!cPositionEncoder.FeedForward(NeuronOCL)) return false; if(!SumAndNormilize(NeuronOCL.getOutput(), cPositionEncoder.getOutput(), Output, 1, false, 0, 0, 0, 1)) return false; //--- return true; }
基于获得的初始数据,我们首先生成一个位置编码张量。然后我们将获得的数值加到输入数据张量当中。
所有操作的执行都受控于调用方法返回值。
误差梯度分布方法的 CNeuronLearnabledPE::calcInputGradients 算法看起来有点复杂。在参数中,它还接收一个指向前一个神经层对象的指针,我们必须将误差梯度传递给该对象。
bool CNeuronLearnabledPE::calcInputGradients(CNeuronBaseOCL *NeuronOCL) { if(!NeuronOCL) return false; if(!DeActivation(cPositionEncoder.getOutput(), cPositionEncoder.getGradient(), Gradient, cPositionEncoder.Activation())) return false; if(!DeActivation(NeuronOCL.getOutput(), NeuronOCL.getGradient(), Gradient, NeuronOCL.Activation())) return false; //--- return true; }
在方法主体中,我们首先检查在参数中收到的指针相关性。
然后,我们为内部对象的激活函数调整自后续层获得的误差梯度。我要提醒您,在初始化期间,我们为内部对象指定了一个激活函数,但 CNeuronLearnabledPE 对象本身没有激活函数。因此,我们的层缓冲器中的误差梯度未经激活函数校正。
下一步是重复误差梯度校正操作。不过,这次我们在前一层的激活函数上使用它。
注意,我们不会经由内部对象传播误差梯度,生成位置编码张量。为了执行该层参数的更新,我们只需在其输出处得到一个误差梯度。我们不需要将误差梯度经由对象传播到前一层,因为生成位置编码张量的模块不应影响输入数据或其嵌入。
为了完成 CNeuronLearnabledPE 类的反向传播验算算法的实现,我们创建一个方法来更新学习参数:updateInputWeights。该方法非常简单:它调用嵌套对象里的同名方法。
bool CNeuronLearnabledPE::updateInputWeights(CNeuronBaseOCL *NeuronOCL) { return cPositionEncoder.UpdateInputWeights(NeuronOCL); }
您可在附件中找到该类,及其所有方法的完整代码。
2.2U-形变换器类
我们继续实现由 U-形变换器方法的作者所提议方式的自研版本。我必须要说,针对编码器和解码器之间的跳接实现架构的决定,尽管表面上很简单,但事实证明所察不实。一方面,我们可以使用对内层标识符的引用,并将数据从编码器传递到解码器。于此并无难处。但是浮现出关于误差梯度经由跳接传播的问题。我们的类中整个反向传播架构,是建立在随后的反向验算期间重写误差梯度之上的。因此,我们经由跳接传播的任何误差梯度,都会在经由神经层跳接对象之间传播梯度操作期间被删除。
作为解决方案,我们可以考虑在一个类中创建整个 U-形变换器架构。但是我们需要一种机制来构造不同数量的编码器-解码器模块,每个模块中拥有不同数量的变换器层。解决方案是递归创建对象。我们将在实现期间更详细地讨论所选机制。
为了实现我们的 U-形变换器模块,我们将创建 CNeuronUShapeAttention 类,该类如同前一个类,将继承 CNeuronBaseOCL 神经层基类的主要功能。
class CNeuronUShapeAttention : public CNeuronBaseOCL { protected: CNeuronMLMHAttentionOCL cAttention[2]; CNeuronConvOCL cMergeSplit[2]; CNeuronBaseOCL *cNeck; //--- virtual bool feedForward(CNeuronBaseOCL *NeuronOCL); virtual bool calcInputGradients(CNeuronBaseOCL *prevLayer); virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL); //--- public: CNeuronUShapeAttention(void) {}; ~CNeuronUShapeAttention(void) { delete cNeck; } //--- virtual bool Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint window_key, uint heads, uint units_count, uint layers, uint inside_bloks, ENUM_OPTIMIZATION optimization_type, uint batch); //--- virtual int Type(void) const { return defNeuronUShapeAttention; } //--- methods for working with files virtual bool Save(int const file_handle); virtual bool Load(int const file_handle); virtual CLayerDescription* GetLayerInfo(void); virtual bool WeightsUpdate(CNeuronBaseOCL *net, float tau); virtual void SetOpenCL(COpenCLMy *obj); };
在类主体中,我们创建了一个内含 2 个多层多头关注度类 CNeuronMLMHAttentionOCL 元素的数组。这些将是当前模块的编码器和解码器。
我们还创建了一个包含 2 个卷积层元素的数组,我们将用它们来与修补打交道。
当前模块的编码器和解码器之间的所有元素,都放置在神经层基类的 cNeck 对象当中,其在本例中是动态的。但是如何将无限数量的区块添加到一个区块中呢?为了回答这个问题,我建议转而研究 Init 对象初始化方法。
如常,在方法参数中,我们接收对象架构的主要常量:
- window — 输入数据窗口的大小(序列 1 个元素的描述向量)
- window_key — 描述自关注 Query、Key、Value 实体序列 1 个元素的内部向量大小
- heads:关注度头的数量
- units_count — 序列中的元素数量
- layers — 一个区块中关注度的层数
- inside_bloks — 嵌套的 U-形变换器模块的数量
参数窗口、window_key、heads、和 layers 无需修改当前和嵌套的 U-形变换器模块。
bool CNeuronUShapeAttention::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint window_key, uint heads, uint units_count, uint layers, uint inside_bloks, ENUM_OPTIMIZATION optimization_type, uint batch) { if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, window * units_count, optimization_type, batch)) return false;
在方法主体中,我们首先调用父类的相同方法,该方法控制接收到的参数,并初始化继承对象。
接下来,我们初始化编码器,并修补拆分对象。
if(!cAttention[0].Init(0, 0, OpenCL, window, window_key, heads, units_count, layers, optimization, iBatch)) return false; if(!cMergeSplit[0].Init(0, 1, OpenCL, 2 * window, 2 * window, 4 * window, (units_count + 1) / 2, optimization, iBatch)) return false;
接着来到初始化方法中最有趣的模块。我们首先检查指定嵌套模块的数量。如果超过 “0”,那么我们创建并初始化 U-形变换器嵌套模块,类似于当前类。不过,序列中的元素数量增加了 2 倍,这与修补拆分结果相对应。我们还将嵌套模块的数量减少了 “1”。
if(inside_bloks > 0) { CNeuronUShapeAttention *temp = new CNeuronUShapeAttention(); if(!temp) return false; if(!temp.Init(0, 2, OpenCL, window, window_key, heads, 2 * units_count, layers, inside_bloks - 1, optimization, iBatch)) { delete temp; return false; } cNeck = temp; }
我们将指向所创建对象的指针保存在 cNeck 变量之中。为此,我们声明了一个动态对象。因此,通过递归调用初始化函数,我们创建了所需数量的嵌套 U-形变换器组。
在最后一个区块中,我们在编码器和解码器之间创建了一个线性依赖的卷积层。
{ CNeuronConvOCL *temp = new CNeuronConvOCL(); if(!temp) return false; if(!temp.Init(0, 2, OpenCL, window, window, window, 2 * units_count, optimization, iBatch)) { delete temp; return false; } cNeck = temp; }
接下来,我们初始化解码器,并修补合并对象。
if(!cAttention[1].Init(0, 3, OpenCL, window, window_key, heads, 2 * units_count, layers, optimization, iBatch)) return false; if(!cMergeSplit[1].Init(0, 4, OpenCL, 2 * window, 2 * window, window, units_count, optimization, iBatch)) return false;
为了剔除不必要的复制操作,我们替换了误差梯度缓冲区。
if(Gradient != cMergeSplit[1].getGradient()) SetGradient(cMergeSplit[1].getGradient()); //--- return true; }
完成方法执行。
接着来到的前馈方法则要简单得多。在其中,我们只按照 U-形变换器算法,逐一调用内层的同名方法。首先,输入数据经由编码器模块传递。
bool CNeuronUShapeAttention::feedForward(CNeuronBaseOCL *NeuronOCL) { if(!cAttention[0].FeedForward(NeuronOCL)) return false;
然后我们拆分修补。
if(!cMergeSplit[0].FeedForward(cAttention[0].AsObject())) return false;
我们调用嵌套模块的前馈方法。
if(!cNeck.FeedForward(cMergeSplit[0].AsObject())) return false;
以这种方式处理过的数据被馈送到解码器。
if(!cAttention[1].FeedForward(cNeck)) return false;
然后是修补合并层。
if(!cMergeSplit[1].FeedForward(cAttention[1].AsObject())) return false;
最后,我们将当前 U-形变换器模块的结果,与接收到的输入数据(跳接)相加,以便保留高频信号。
if(!SumAndNormilize(NeuronOCL.getOutput(), cMergeSplit[1].getOutput(), Output, 1, false)) return false; //--- return true; }
完成方法执行。
实现前馈验算之后,我们转到创建反向传播方法。首先,我们创建误差梯度传播方法 CNeuronUShapeAttention::calcInputGradients,在该参数中,我们接收一个指向前一层对象的指针,其内是我们必须传播的误差梯度。
bool CNeuronUShapeAttention::calcInputGradients(CNeuronBaseOCL *prevLayer) { if(!prevLayer) return false;
在方法主体中,我们立即检查接所接收指针的相关性。
由于我们采用了数据缓冲区的替身,因此误差梯度已经存储在嵌套修补合并层缓冲区之中。故此,我们可以调用相应的误差梯度分派方法。
if(!cAttention[1].calcHiddenGradients(cMergeSplit[1].AsObject())) return false;
接下来,我们通过解码器传播误差梯度。
if(!cNeck.calcHiddenGradients(cAttention[1].AsObject())) return false;
然后,我们按顺序将误差梯度传递给 U-形变换器的内部模块、修补拆分层和编码器。
if(!cMergeSplit[0].calcHiddenGradients(cNeck.AsObject())) return false; if(!cAttention[0].calcHiddenGradients(cMergeSplit[0].AsObject())) return false; if(!prevLayer.calcHiddenGradients(cAttention[0].AsObject())) return false;
之后,我们将当前模块(跳接)的输入和输出处的误差梯度相加。
if(!SumAndNormilize(prevLayer.getGradient(), Gradient, prevLayer.getGradient(), 1, false)) return false; if(!DeActivation(prevLayer.getOutput(), prevLayer.getGradient(), prevLayer.getGradient(), prevLayer.Activation())) return false; //--- return true; }
针对前一层的激活函数调整误差梯度,完成该方法。
误差梯度传播之后是模型可训练参数的调整。该功能在 CNeuronUShapeAttention::updateInputWeights 方法中实现。该方法算法十分简单。我们只需逐个调用嵌套对象的相应方法即可。
bool CNeuronUShapeAttention::updateInputWeights(CNeuronBaseOCL *NeuronOCL) { if(!cAttention[0].UpdateInputWeights(NeuronOCL)) return false; if(!cMergeSplit[0].UpdateInputWeights(cAttention[0].AsObject())) return false; if(!cNeck.UpdateInputWeights(cMergeSplit[0].AsObject())) return false; if(!cAttention[1].UpdateInputWeights(cNeck)) return false; if(!cMergeSplit[1].UpdateInputWeights(cAttention[1].AsObject())) return false; //--- return true; }
不要忘记控制每个步骤的结果。
应提供有关文件操作方法的更多信息。在 CNeuronUShapeAttention::Save 数据保存方法中的操作相当简单。我们逐一调用父类和所有嵌套对象的相应方法。
bool CNeuronUShapeAttention::Save(const int file_handle) { if(!CNeuronBaseOCL::Save(file_handle)) return false; for(int i = 0; i < 2; i++) { if(!cAttention[i].Save(file_handle)) return false; if(!cMergeSplit[i].Save(file_handle)) return false; } if(!cNeck.Save(file_handle)) return false; //--- return true; }
至于数据加载方法 CNeuronUShapeAttention::Load,存在一些细微差别。它们与嵌套 U-形变换器模块的如何规划加载有关。首先,我们调用父类方法来加载继承的对象。
bool CNeuronUShapeAttention::Load(const int file_handle) { if(!CNeuronBaseOCL::Load(file_handle)) return false;
然后,在一个循环中,我们加载编码器、解码器、和修补层数据。
for(int i = 0; i < 2; i++) { if(!LoadInsideLayer(file_handle, cAttention[i].AsObject())) return false; if(!LoadInsideLayer(file_handle, cMergeSplit[i].AsObject())) return false; }
然后我们需要加载嵌套模块。如您所记,我们在此处用到指向对象的动态指针。因此,有一些选项。变量中的指针可能无效,或者它也许指向不同类的对象。
我们从文件中读取所需对象的类型。我们还检查 cNeck 变量指向的对象类型。如果类型不同,我们将删除现有对象。
int type = FileReadInteger(file_handle); if(!!cNeck) { if(cNeck.Type() != type) delete cNeck; }
接下来,我们检查变量中指针的相关性,并在必要时创建相应类型的新对象。
if(!cNeck) { switch(type) { case defNeuronUShapeAttention: cNeck = new CNeuronUShapeAttention(); if(!cNeck) return false; break; case defNeuronConvOCL: cNeck = new CNeuronConvOCL(); if(!cNeck) return false; break; default: return false; } }
准备工作完成后,我们从文件里加载对象数据。
cNeck.SetOpenCL(OpenCL); if(!cNeck.Load(file_handle)) return false;
在方法末端,我们替换误差梯度缓冲区。
if(Gradient != cMergeSplit[1].getGradient()) SetGradient(cMergeSplit[1].getGradient()); //--- return true; }
完成方法执行。
您可在附件中找到所有类、及其方法的完整代码,以及准备本文时用到的所有程序。
2.3模型架构
在创建构建模型的新类之后,我们继续描述可训练模型的架构。对于它们,我们创建文件 “...\Experts\UShapeTransformer\Trajectory.mqh”。
如前所述,U-形变换器方法的作者提议分 2 步训练模型。第一步,我们将训练编码器来恢复被掩码数据。因此,编码器模型架构的描述将转移到名为 CreateEncoderDescriptions 的单独方法之中。在方法参数中,我们传递一个指向动态数组对象的指针,用来记录模型架构的描述。
bool CreateEncoderDescriptions(CArrayObj *encoder) { //--- CLayerDescription *descr; //--- if(!encoder) { encoder = new CArrayObj(); if(!encoder) return false; }
在方法主体中,我们检查所收指针的相关性,并在必要时创建动态数组的新实例。
接下来,我们创建一个足够大小的源数据层。
//--- Encoder encoder.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(!encoder.Add(descr)) { delete descr; return false; }
输入数据以“原始”形式馈送到模型中。在批量归一化层中对其进行预处理。
//--- layer 1 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBatchNormOCL; descr.count = prev_count; descr.batch = 1000; descr.activation = None; descr.optimization = ADAM; if(!encoder.Add(descr)) { delete descr; return false; }
我们保存批量归一化层编号,以便在逆向归一化层中引用它。
为了训练编码器,我们使用数据掩码。为了执行掩码,我们创建了一个 Dropout 层。
//--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronDropoutOCL; descr.count = prev_count; descr.probability = 0.4f; descr.activation = None; if(!encoder.Add(descr)) { delete descr; return false; }
掩码概率设置为 0.4,相当于输入数据的 40%。
请注意,我们遮掩的是原始输入数据,而不是其嵌入。
在下一步中,我们用 2 个卷积层来生成掩码输入数据的嵌入。
//--- layer 3 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConvOCL; prev_count = descr.count = HistoryBars; descr.window = BarDescr; descr.step = descr.window; int prev_wout = descr.window_out = EmbeddingSize / 2; if(!encoder.Add(descr)) { delete descr; return false; } //--- layer 4 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConvOCL; descr.count = prev_count; descr.window = prev_wout; descr.step = prev_wout; prev_wout = descr.window_out = EmbeddingSize; if(!encoder.Add(descr)) { delete descr; return false; }
然后,我们将位置编码添加到输入数据之中。在这种情况下,我们使用可学习的位置编码。
//--- layer 5 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronLearnabledPE; descr.count = prev_count*prev_wout; if(!encoder.Add(descr)) { delete descr; return false; }
现在我们添加 U-形编码器模块。在层中采用以下参数。
- descr.count:序列大小
- descr.window:描述一个元素的向量大小
- descr.step:关注度头的数量
- descr.window_out:关注度内部实体的元素大小
- descr.layers:每个编码器模块中的层数
- descr.batch:嵌套的 U-形编码器模块的数量
如您所见,大多数参数都取自关注度模块。
//--- layer 6 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronUShapeAttention; descr.count = prev_count; descr.window = prev_wout; descr.step = 4; descr.window_out = EmbeddingSize; descr.layers = 3; descr.batch = 2; if(!encoder.Add(descr)) { delete descr; return false; }
接下来是 3 个全连接层的决策模块。
//--- layer 7 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = LatentCount; descr.activation=SIGMOID; if(!encoder.Add(descr)) { delete descr; return false; } //--- layer 8 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = LatentCount; descr.activation=LReLU; if(!encoder.Add(descr)) { delete descr; return false; } //--- layer 9 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; prev_count=descr.count = BarDescr*(HistoryBars+NForecast); descr.activation=TANH; if(!encoder.Add(descr)) { delete descr; return false; }
请注意,在编码器的输出端,我们期望接收到重建的输入数据,以及若干个预测值。以这种方式,我们希望训练 U-形编码器不仅在输入数据中捕获依赖关系,而且还为构造预测值查找参考点。
为了完成编码器,我们添加了一个逆向归一化层,以便将重建和预测的数值与原始输入数据进行比较。
//--- layer 10 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronRevInDenormOCL; prev_count = descr.count = prev_count; descr.activation = None; descr.optimization = ADAM; descr.layers = 1; if(!encoder.Add(descr)) { delete descr; return false; } //--- return true; }
更改编码器架构需要更改隐藏层常量,以便从模型中获取数据。
#define LatentLayer 9
此外,更改编码器结果层的大小还需要调整扮演者和评论者模型的架构,才能用此数据。这些模型的架构在 CreateDescriptions 方法中提供。在参数中,该方法接收指向 2 个动态数组的指针,记录相应模型的架构。
bool CreateDescriptions(CArrayObj *actor, CArrayObj *critic) { //--- CLayerDescription *descr; //--- if(!actor) { actor = new CArrayObj(); if(!actor) return false; } if(!critic) { critic = new CArrayObj(); if(!critic) return false; }
在方法主体中,我们检查收到的指针,并在必要时创建新的动态数组。
首先,我们描述扮演者架构。我们取账户状态描述向量投喂模型。
//--- Actor actor.Clear(); //--- Input layer if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; int prev_count = descr.count = AccountDescr; descr.activation = None; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
获得的数据由全连接层处理。
//--- layer 1 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; prev_count = descr.count = EmbeddingSize; descr.activation = SIGMOID; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
接下来,我们添加 5 个交叉关注度层,它将有关帐户状态和持仓的信息,与编码器生成的重建和预测值的数据进行比较。
for(int i = 0; i < 5; i++) { if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronCrossAttenOCL; { int temp[] = {1, BarDescr}; ArrayCopy(descr.units, temp); } { int temp[] = {EmbeddingSize, HistoryBars+NForecast}; ArrayCopy(descr.windows, temp); } descr.window_out = 32; descr.step = 4; descr.activation = None; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; } }
注意,我们不会在扮演者政策训练和操作阶段遮掩输入数据。然而,期待经过训练的编码器不仅会预测环境的后续状态,还会充当历史值的一种过滤器,从中去除各种噪声。
在模型的末尾,有一个带有随机“头”的决策模块。
//--- layer 8 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = LatentCount; descr.activation = SIGMOID; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; } //--- layer 9 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = 2 * NActions; descr.activation = None; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; } //--- layer 10 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronVAEOCL; descr.count = NActions; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
评论者架构已作相应调整。我不会在此处描述这些变化。我建议您配合附件中提供的代码熟悉它们。
2.4编码训练 EA
我们已经讲述了模型架构。现在我们转到编码器模型训练 EA。在这项工作中,我们用到为上一篇文章收集的训练数据集。在该数据集中,我们用到了一整套历史数据来描述环境的状态。该方式有其优点和缺点。优点包括剔除模型内用于累积历史数据的堆栈,以及用采样状态来训练模型的能力。不过,它的缺点是训练数据集文件的显著增长,因为它包含重复多次的数据。此外,在操作期间,模型在每个步骤中都会重复重新计算历史数据,以达到分析历史的整个深度。但在这个阶段,重要的是要清晰地比较投喂给模型的数据,和掩码后的数据恢复。因此,为了测试提议的方法,我决定采用该方案。
新 EA “...\Experts\UShapeTransformer\StudyEncoder.mq5” 主要基于上一篇文章中的相关 EA。因此,我们不会详细研究它的所有方法。我们来研究模型训练方法:Train。
void Train(void) { //--- vector<float> probability = GetProbTrajectories(Buffer, 0.9); //--- vector<float> result, target; bool Stop = false; //--- uint ticks = GetTickCount();
在方法开始处,我们做了一些准备工作。我们基于它们的返回报定义采样轨迹的概率,并声明局部变量。
然后,我们采用用户在外部参数中指定的迭代次数规划一个模型训练循环。
for(int iter = 0; (iter < Iterations && !IsStopped() && !Stop); iter ++) { int tr = SampleTrajectory(probability); int i = (int)((MathRand() * MathRand() / MathPow(32767, 2)) * (Buffer[tr].Total - 2 - NForecast)); if(i <= 0) { iter--; continue; }
在循环的主体中,我们采集轨迹,及其 1 个状态,来训练模型。我们将有关所选状态的信息加载到数据缓冲区之中。
bState.AssignArray(Buffer[tr].States[i].state); //--- State Encoder if(!Encoder.feedForward((CBufferFloat*)GetPointer(bState), 1, false, (CBufferFloat*)NULL)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
然后,我们调用模型的前馈验算方法,将加载的数据传递给它。
前馈验算成功执行之后,模型的结果缓冲区会存储历史环境参数及其预测值的一些表示形式。我们此刻对具体结果不感兴趣。
现在我们需要准备有关即将到来的、及所分析环境状态的真实数据。如同上一篇文章,我们首先从经验回放缓冲区加载后续环境状态。
//--- Collect target data if(!Result.AssignArray(Buffer[tr].States[i + NForecast].state)) continue; if(!Result.Resize(BarDescr * NForecast)) continue;
在前馈验算期间,用输入到模型中的数据来补充它们。正如我们所见,在缓冲区中我们得到未掩码数据。
if(!Result.AddArray(GetPointer(bState))) continue;
现在我们已经准备好了目标值缓冲区,我们可以执行模型的反向传播验算,并调整权重,以便将误差最小化。
if(!Encoder.backProp(Result, (CBufferFloat*)NULL)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
更新模型参数后,我们会通知用户训练进度,并转到下一次迭代。
if(GetTickCount() - ticks > 500) { double percent = double(iter) * 100.0 / (Iterations); string str = StringFormat("%-14s %6.2f%% -> Error %15.8f\n", "Encoder", percent, Encoder.getRecentAverageError()); Comment(str); ticks = GetTickCount(); } }
请务必始终检查操作执行结果。
模型训练循环的所有迭代成功完成后,我们将清除图表上的注释字段。
Comment(""); //--- PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Encoder", Encoder.getRecentAverageError()); ExpertRemove(); //--- }
我们将有关模型训练结果的信息输出到 MetaTrader 5 日志中,并启动 EA 终止。
您可在附件中找到 EA,及其所有方法的完整代码。
扮演者和评论者政策训练 EA “...\Experts\RevIN\Study.mq5” 拷贝自上一篇文章,几乎没有变化。有关环境交互 EA 也是如此。因此,我们不会在本文中详细研究它们的算法。您可在附件中找到本文中用到的所有程序的完整代码。
我要再次强调,在所有 EA 中,除了编码器训练 EA 之外,编码器模型必须禁用训练模式。
Encoder.TrainMode(false);
这将禁用原始输入数据的掩码。
3. 测试
我们已讨论了 U-形变换器方法的理论层面,并利用 MQL5 为实现所提议方法做了大量工作。现在是时候采用真实的历史数据来检验我们的工作成果了。
如上所述,我们将采用为上一篇文章收集的训练数据集来训练模型。我们现在不会研究收集训练数据集方法的详细描述,因为它们早前已经详述过。
该模型使用 2023 年 EURUSD H1 的历史数据进行训练。经过训练的扮演者政策在 MetaTrader 5 策略测试器中采用 2024 年 1 月的历史数据进行测试,配以相同的品种和时间帧。
根据 U-形变换器方法作者提议的方式,我们分 2 个阶段训练模型。首先,我们采用预先收集的训练数据训练编码器。
此处应该注意的是,编码器模型仅分析历史品种数据。因此,在编码器训练期间,我们不需要再收集额外的验算。我们可立即设置足够多的模型训练迭代,并等待训练过程完成。
在该阶段,我注意到环境状态预测品质发生了积极的变化。
扮演者政策学习的第二阶段是迭代的。在该阶段,通过使用 EA “...\Experts\UShapeTransformer\Research.mq5”,和当前扮演者政策,往训练数据集里添加新的验算,交替训练扮演者政策,及有关环境的附加信息集合。
通过迭代学习,我能够获得一个能够在训练和测试数据集上均产生盈利的模型。
在测试期间,该模型进行了 26 笔交易。其中 20 笔以盈利收盘,为 76.92%。盈利因子为 2.87。
得到的结果是有前景的,但 1 个月的测试区间太短,无法可靠地评估模型的稳定性。
结束语
在本文中,我们领略了 U-形变换器架构,它是专为时间序列预测而设计的。所提议方法结合了变换器和全连接感知器的优点,可以有效地捕获时间数据中的长期依赖关系,以及高频上下文的处理。
U-形变换器的主要成就之一是使用了跳接。以及可训练修补合并和拆分操作。这令模型能够有效地提取不同尺度的特征,并更好地捕获信息。
在本文的实践部分,我们利用 MQL5 实现了所提议方法。我们采用真实历史数据训练和测试了所生成模型。我们收获了相当不错的测试结果。
然而,我要再次强调,本文中讲述的所有程序仅用于演示技术,并不准备在实际市场中运用。一个月间隔的测试结果只能展示该模型的能力,并不能确认其在较长时间内的稳定运行。
参考
文中所用程序
# | 名称 | 类型 | 说明 |
---|---|---|---|
1 | Research.mq5 | EA | 样本收集 EA |
2 | ResearchRealORL.mq5 | EA | 运用 Real-ORL 方法收集示例的 EA |
3 | Study.mq5 | EA | 模型训练 EA |
4 | StudyEncoder.mq5 | EA | 编码训练 EA |
5 | Test.mq5 | EA | 模型测试 EA |
6 | Trajectory.mqh | 类库 | 系统状态定义结构 |
7 | NeuroNet.mqh | 类库 | 创建神经网络的类库 |
8 | NeuroNet.cl | 代码库 | OpenCL 程序代码库 |
本文由MetaQuotes Ltd译自俄文
原文地址: https://www.mql5.com/ru/articles/14766


