
神经网络变得简单(第 83 部分):“构象”时空连续关注度转换器算法
概述
金融市场行为的不可预测性可与天气的波动性相提并论。然而,人类在天气预报领域已经做了很多工作。如此,我们现在可以非常信任气象学家提供的天气预报。我们能否利用它们的发展来预测金融市场的“天气”?在本文中,我们将领略“构象”时空连续关注度转换器的复杂算法,其是为天气预报而开发的,并在论文《构象:在天气预报的视觉变换器中嵌入连续关注度》中阐述。在他们的工作中,该方法的作者提出了连续关注度算法。他们将其与我们在上一篇文章中讨论的神经 ODE 相结合。
1. 构象算法
构象设计用于在多头关注度机制中实现连续性来研究随时间的连续天气变化。关注度机制在变换器架构中被编码为可微函数,用于模拟复杂的天气动力学。
最初,该方法的作者面临着构建一个模型的任务,该模型以 (XN*W*H, T) 的形式接收天气数据作为输入。其中 N 是温度、风速、压力等天气变量的数量。W*H 是指变量的空间分辨率。T 是系统发展的时间。该模型接收随时间 t 变化的天气变量,研究时空系统的演变,并预测下一个时间步 t+1 的天气。
由于天气会随着时间的推移而持续变化,故记录固定时间内所提供数据的连续变化也很重要。这个思路是利用微分方程求解器来学习天气数据的连续潜在表示。因此,该模型不仅预测时间 'T' 处的天气变量的值,而且定积分还研究天气变量的变化,譬如温度从初始时间到时间 'T'。该系统可以表示为:
天气信息变化很大,很难在时间和空间两者上进行预测。计算每个天气变量的时间导数,来保留天气动态,并从离散数据中提供更好的特征提取。该方法的作者在像素级别执行选择性区分,从而捕捉天气现象随时间的连续变化。
导数归一化是确保深度学习模型行为稳定性的最重要步骤之一。该方法的作者将归一化的思路扩展为模型架构的单独元素。他们探索了归一化在直接应用于导数时的作用。在这篇论文中,他们研究了两种最常见的归一化方法,以及预微分层对模型性能的影响,从而展示它们在连续系统中的优点。
关注度是变换器架构的关键分量之一。它基于的思路是,在最终预测步骤中识别源数据中最重要部分。尽管变换器在解决各种问题方面取得了成功,但它针对高度动态系统学习信息嵌入的能力仍然受到限制,诸如天气预报。构象方法的作者开发了连续关注度机制来模拟天气变量的连续变化。首先,他们把初始状态元素之间依赖关系的分析,替换为环境不同状态的相应参数之间的关注。这允许计算每个时变天气变量的上下文嵌入空间。该步骤可确保模型在批处理中处理不同状态的相同变量,取代访问处于相同环境状态的模块。变量变换的学习,是通过为每个源数据样本的每个变量分配自己的 Query、Key 和 Value,类似于在单一环境状态中完成的方式。关注度机制计算变量在不同样本(相同位置)之间的依赖性估测。与传统的关注度机制类似,针对不同批次学习的依赖权重,能用于与这些变量相关信息的聚合或加权。
该修改允许模型捕获不同环境状态下相同天气变量之间的关系、或依赖性。这在天气预报场景中被证明是有用的,其中该模型能够表示每个天气变量的连续演变特征。为了确保持续学习,该方法的作者将导数引入了连续关注度机制。微分方程表示物理系统随时间变化的动态,并考虑了缺失数据值。该方法的作者将关注度机制与微分方程学习范式相结合,从而为大气依据空间和时间特征的变化建模。进而,这种方式消除了模型中复杂物理方程建模相关的限制。构象学习从一个时间步到另一个时间步的过渡变化,取代只对某个时间戳进行预测,这对于捕捉天气当中不可预测的变化非常重要。
为了计算连续关注度,该方法的作者提议计算每个数据样本中相同变量的相似性导数。假设我们有 2 个大小为 (N*W*H) 的输入样本。我们在时间 t0 和 t1 处分别将它们表示为 X0 和 X1。在两个样本中,每个变量都有自己的张量 Q、K 和 V。连续关注度的计算方式如下:
获得的结果是输入数据中类似变量的值在某个时间点 t0 和 t1 的关注度加权和。所提议过程计算了输入数据中跨越所有时间步长的相似变量之间的关注度,令模型能够跨越整个输入样本序列捕获变量之间的关系或交互。
为了进一步探索气象信息的连续特征,构象的作者往神经 ODE 模型里添加了层。由于自适应大小的求解器比固定大小的求解器具有更高的精度,故该方法的作者选择了 Dormand-Prince 方法 (Dopri5)。这允许研究天气随时间变化的最小可能。构象的完整工作流程和神经 ODE 层的放置,如下展示在作者的方法可视化之中。
2. 利用 MQL5 实现
在回顾了构象方法的理论层面之后,我们现在转到利用 MQL5 实现所提议的方法实施。我们将在新类 CNeuronConformer 中实现主要功能,它是自神经层基类 CNeuronBaseOCL 派生而来。
2.1CNeuronConformer 类架构
在 CNeuronConformer 类的结构中,我们已经看到了熟悉的方法,这些方法在所有实现关注度方法的类中都被重新定义。然而,连续关注度与以前研究的关注度方法有很大不同。因此,我决定从头开始实现算法。无论如何,该实现将用到来自以前工作的开发。
为了编写层架构的主要参数,我们引入 5 个变量:
- iWindow – 在初始数据张量中描述一个参数的向量大小。
- iDimension – 一个 Query、Key、Value 实体的向量维度。
- iHeads — 关注度头的数量。
- iVariables – 描述一个环境状态的参数数量。
- iCount – 所分析环境的状态数量(初始数据序列的长度)。
为了生成 Query、Key 和 Value 实体,我们如同以前在类似情况下一样,使用卷积层 cQKV。这种方式允许我们并行实现所有 3 个实体。我们将在基础神经层 cdQKV 中写入实体随时间变化的导数。
与原生变换器算法类似,依赖系数将保存在 Score 矩阵当中。但在该实现中,我们不会在主程序端创建矩阵的副本。我们只会在 OpenCL 关联环境中创建一个缓冲区。在 CNeuronConformer 类的局部变量 iScore 中,我们将保存指向缓冲区的指针。
多头关注度的结果将保存在基类神经层 AttentionOut 的缓冲区当中。我们将利用卷积层 cW0 来降低所获数据的维数。
根据构象算法,关注度模块后随常微分方程的神经层模块。针对它们,我们将创建 cNODE 数组。类似地,对于 FeedForward 模块,我们将创建 cFF 数组。
class CNeuronConformer : public CNeuronBaseOCL { protected: //--- int iWindow; int iDimension; int iHeads; int iVariables; int iCount; //--- CNeuronConvOCL cQKV; CNeuronBaseOCL cdQKV; int iScore; CNeuronBaseOCL cAttentionOut; CNeuronConvOCL cW0; CNeuronNODEOCL cNODE[3]; CNeuronConvOCL cFF[2]; //--- virtual bool feedForward(CNeuronBaseOCL *NeuronOCL); virtual bool attentionOut(void); //--- virtual bool AttentionInsideGradients(void); virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL); public: CNeuronConformer(void) {}; ~CNeuronConformer(void) {}; //--- virtual bool Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint window_key, uint heads, uint variables, uint units_count, ENUM_OPTIMIZATION optimization_type, uint batch); //--- virtual bool calcInputGradients(CNeuronBaseOCL *prevLayer); //--- virtual int Type(void) const { return defNeuronConformerOCL; } //--- methods for working with files virtual bool Save(int const file_handle); virtual bool Load(int const file_handle); //--- virtual void SetOpenCL(COpenCLMy *obj); virtual CLayerDescription* GetLayerInfo(void); };
该类的所有内部对象都声明为静态。这允许我们将类的构造函数和析构函数“留空”。根据用户需求在 Init 方法中实现类对象的初始化。在方法参数中,我们传递对象架构的主要参数。
bool CNeuronConformer::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint window_key, uint heads, uint variables, uint units_count, ENUM_OPTIMIZATION optimization_type, uint batch) { if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, window * variables * units_count, optimization_type, batch)) return false;
在方法的主体中,我们调用父类的相关方法,其内针对接收参数和继承对象初始化实现了最小必要控制。我们可以从方法返回的逻辑结果中检查控制和初始化的结果。
接下来,我们初始化内层 cQKV,用于生成 Query、Key 和 Value 实体。请注意,根据构象方法,将为每个单独的变量创建实体。因此,窗口大小和卷积步长等于一个变量的嵌入向量的长度。卷积元素的数量,等于描述一个环境状态的变量数量,乘以正在分析的该类状态数量的乘积。卷积过滤器的数量,等于 3 个实体的长度与关注度头数量乘积。
if(!cQKV.Init(0, 0, OpenCL, window, window, 3 * window_key * heads, variables * units_count, optimization, iBatch)) return false;
上述 2 个方法成功完成之后,我们将得到的参数保存在内部变量之中。
iWindow = int(fmax(window, 1)); iDimension = int(fmax(window_key, 1)); iHeads = int(fmax(heads, 1)); iVariables = int(fmax(variables, 1)); iCount = int(fmax(units_count, 1));
我们初始化内层,写入随时间变化的偏导数。
if(!cdQKV.Init(0, 1, OpenCL, 3 * iDimension * iHeads * iVariables * iCount, optimization, iBatch)) return false;
创建关注度系数缓冲区。
iScore = OpenCL.AddBuffer(sizeof(float) * iCount * iHeads * iVariables * iCount, CL_MEM_READ_WRITE); if(iScore < 0) return false;
通过初始化内层 AttentionOut 和 cW0,我们完成了关注度模块对象的准备工作。
if(!cAttentionOut.Init(0, 2, OpenCL, iDimension * iHeads * iVariables * iCount, optimization, iBatch)) return false; if(!cW0.Init(0, 3, OpenCL, iDimension * iHeads, iDimension * iHeads, iWindow, iVariables * iCount, optimization, iBatch)) return false;
请注意,关注度模块的输出数据维度必须与接收的源数据维度匹配。进而,由于构象算法包括分析一个变量在不同环境状态下的依赖性,我们还要运作在单个变量的框架内进行降维。
常微分方程的所有用到的神经层都具有相同的架构。这允许我们在循环中初始化它们。
for(int i = 0; i < 3; i++) if(!cNODE[i].Init(0, 4 + i, OpenCL, iWindow, iVariables, iCount, optimization, iBatch)) return false;
如此,现在我们只需初始化 FeedForward 模块对象。
if(!cFF[0].Init(0, 7, OpenCL, iWindow, iWindow, 4 * iWindow, iVariables * iCount, optimization, iBatch)) return false; if(!cFF[1].Init(0, 8, OpenCL, 4 * iWindow, 4 * iWindow, iWindow, iVariables * iCount, optimization, iBatch)) return false;
在方法完成之前,我们规划将类的梯度缓冲区指针替换为 FeedForward 模块最后一层的梯度缓冲区。这种技术令我们能够避免不必要的数据复制,且我们已在许多其它方法的实现中多次用到它。
if(getGradientIndex() != cFF[1].getGradientIndex()) SetGradientIndex(cFF[1].getGradientIndex()); //--- return true; }
2.2实现前馈验算
初始化类实例之后,我们继续实现前馈算法。我们来关注由构象方法的作者提议的连续关注度算法。它使用 Query 和 Key 实体随时间的部分导数。
显然,在模型训练阶段,我们没有比这些实体对时间的依赖函数最接近的近似值。因此,我们将从不同的角度入手来定义导数的问题。首先,我们回顾一下函数导数的几何含义。它指出函数在特定点上关于参数的导数是该点处切线到函数图的倾角。它展示当参数按 1 变化时函数值的近似(或线性函数的精确)变化。
在我们的输入数据中,我们获得了具有固定时间步长的环境状态,其等于所分析的时间帧。为了简化我们的实现,我们将忽略特定的时间帧,并将 2 个后续状态之间的时间步长设置为 “1”。因此,我们可以通过计算函数值在从前一状态到当前状态、及从当前状态到下一状态之间,2 次连串转换中的平均变化,分析获得函数导数的一些近似值。
我们在 OpenCL 关联环境端的 TimeDerivative 内核中实现所提议的机制。在内核参数中,我们将传递 2 个缓冲区的指针:输入数和结果。我们还传递一个实体的维度。
__kernel void TimeDerivative(__global float *qkv, __global float *dqkv, int dimension) { const size_t pos = get_global_id(0); const size_t variable = get_global_id(1); const size_t head = get_global_id(2); const size_t total = get_global_size(0); const size_t variables = get_global_size(1); const size_t heads = get_global_size(2);
我们计划从 3 个维度启动内核:
- 所分析环境状态数量,
- 描述一个环境状态的变量数量,
- 关注度头数量。
在内核主体中,我们立即在所有 3-个维度中识别当前线程。之后,我们判定缓冲区中正在处理的实体的偏移。出于方便起见,我们为原始数据和结果使用相同大小的缓冲区。因此,偏移班次将雷同。
const int shift = 3 * heads * variables * dimension; const int shift_query = pos * shift + (3 * variable * heads + head) * dimension; const int shift_key = shift_query + heads * dimension;
接下来,我们将规划一个循环,计算一个实体的所有元素的偏差。首先,我们分析判定 Query 的导数。
for(int i = 0; i < dimension; i++) { //--- dQ/dt { int count = 0; float delta = 0; float value = qkv[shift_query + i]; if(pos > 0) { delta = value - qkv[shift_query + i - shift]; count++; } if(pos < (total - 1)) { delta += qkv[shift_query + i + shift] - value; count++; } if(count > 0) dqkv[shift_query + i] = delta / count; }
此处我们应该关注序列的第一个、和最后一个元素的特殊情况。在这些状态下,我们只有一个过渡。我们不会令算法复杂化,仅用可用的数据。
与此类似,我们计算 Key 的导数。
//--- dK/dt { int count = 0; float delta = 0; float value = qkv[shift_key + i]; if(pos > 0) { delta = value - qkv[shift_key + i - shift]; count++; } if(pos < (total - 1)) { delta += qkv[shift_key + i + shift] - value; count++; } if(count > 0) dqkv[shift_key + i] = delta / count; } } }
在判定随时间变化的偏导数之后,我们拥有了执行连续关注度所需的所有数据。在 OpenCL 关联环境端,我们在 FeedForwardContAtt 内核中实现提议的算法。在内核参数中,我们将传递 4 个数据缓冲区的指针:2 个初始数据缓冲区(实体及其衍生物)、一个依赖系数矩阵缓冲区、和一个多头关注度结果缓冲区。此外,在内核参数中,我们传递 2 个常量:一个实体的向量维度,和关注度头的数量。
__kernel void FeedForwardContAtt(__global float *qkv, __global float *dqkv, __global float *score, __global float *out, int dimension, int heads) { const size_t query = get_global_id(0); const size_t key = get_global_id(1); const size_t variable = get_global_id(2); const size_t queris = get_global_size(0); const size_t keis = get_global_size(1); const size_t variables = get_global_size(2);
在内核主体中,一如既往,我们首先识别任务空间各个维度的当前线程。在本例中,我们使用一个 3-维任务空间。局部组是针对一个变量的一个请求内创建的。
此处我们还为中间数据声明了一个局部数组。
const uint ls_score = min((uint)keis, (uint)LOCAL_ARRAY_SIZE); __local float local_score[LOCAL_ARRAY_SIZE];
接下来,我们根据关注度头的数量运行一个迭代循环。在循环主体中,我们按顺序针对所有关注度头执行数据分析。
for(int head = 0; head < heads; head++) { const int shift = 3 * heads * variables * dimension; const int shift_query = query * shift + (3 * variable * heads + head) * dimension; const int shift_key = key * shift + (3 * variable * heads + heads + head) * dimension; const int shift_out = dimension * (heads * (query * variables + variable) + head); int shift_score = keis * (heads * (query * variables + variable) + head) + key;
此处,我们首先判定所需元素在数据缓冲区的偏移。之后,我们计算依赖系数。这些系数分 3 个阶段判定。首先,我们计算指数值 d/dt(QK),并将它们保存在依赖系数缓冲区的相应元素之中。计算是在一个工作组的并行线程中执行。
//--- Score float scr = 0; for(int d = 0; d < dimension; d++) scr += qkv[shift_query + d] * dqkv[shift_key + d] + qkv[shift_key + d] * dqkv[shift_query + d]; scr = exp(min(scr / sqrt((float)dimension), 30.0f)); score[shift_score] = scr; barrier(CLK_LOCAL_MEM_FENCE);
在第二步中,我们收集全部所获数值的总和。
if(key < ls_score) { local_score[key] = scr; for(int k = ls_score + key; k < keis; k += ls_score) local_score[key] += score[shift_score + k]; } barrier(CLK_LOCAL_MEM_FENCE); //--- int count = ls_score; do { count = (count + 1) / 2; if(key < count) { if((key + count) < keis) { local_score[key] += local_score[key + count]; local_score[key + count] = 0; } } barrier(CLK_LOCAL_MEM_FENCE); } while(count > 1);
在第三步中,我们把依赖系数进行归一化。
score[shift_score] /= local_score[0];
barrier(CLK_LOCAL_MEM_FENCE);
在循环迭代结束时,我们根据上面定义的依赖系数计算关注度模块结果的值。
shift_score -= key; for(int d = key; d < dimension; d += keis) { float sum = 0; int shift_value = (3 * variable * heads + 2 * heads + head) * dimension + d; for(int v = 0; v < keis; v++) sum += qkv[shift_value + v * shift] * score[shift_score + v]; out[shift_out + d] = sum; } barrier(CLK_LOCAL_MEM_FENCE); } //--- }
在 OpenCL 关联环境端创建实现连续关注度算法的内核之后,我们需要从主程序实现调用上述创建的内核。为此,我们将 attentionOut 方法添加到 CNeuronConformer 类之中。
我们不会将内核调用拆分为单独的方法,因为它们是并行调用的。不过,由于任务空间的差异,我们在 OpenCL 程序端拆分了算法。
由于该方法仅为在类内调用而创建,故其算法完全基于内部对象和变量的使用。这令完全剔除方法参数成为可能。
bool CNeuronConformer::attentionOut(void) { if(!OpenCL) return false;
在方法主体中,我们检查指针与 OpenCL 关联环境的相关性。之后,我们准备调用第一个内核来定义派生实体。
首先,我们定义任务空间。
bool CNeuronConformer::attentionOut(void) { if(!OpenCL) return false; //--- Time Derivative { uint global_work_offset[3] = {0, 0, 0}; uint global_work_size[3] = {iCount, iVariables, iHeads};
然后我们将参数传递给内核。
ResetLastError(); if(!OpenCL.SetArgumentBuffer(def_k_TimeDerivative, def_k_tdqkv, cQKV.getOutputIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgumentBuffer(def_k_TimeDerivative, def_k_tddqkv, cdQKV.getOutputIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgument(def_k_TimeDerivative, def_k_tddimension, int(iDimension))) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; }
把内核放入执行队列当中。
if(!OpenCL.Execute(def_k_TimeDerivative, 3, global_work_offset, global_work_size)) { printf("Error of execution kernel %s: %d", __FUNCTION__, GetLastError()); return false; } }
将第二个内核放入执行队列的正常算法类似。不过,这次我们添加了工作组任务空间。
//--- MH Attention Out { uint global_work_offset[3] = {0, 0, 0}; uint global_work_size[3] = {iCount, iCount, iVariables}; uint local_work_size[3] = {1, iCount, 1};
此外,传输的参数数量也会增加。
ResetLastError(); if(!OpenCL.SetArgumentBuffer(def_k_FeedForwardContAtt, def_k_caqkv, cQKV.getOutputIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgumentBuffer(def_k_FeedForwardContAtt, def_k_cadqkv, cdQKV.getOutputIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgumentBuffer(def_k_FeedForwardContAtt, def_k_cascore, iScore)) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgumentBuffer(def_k_FeedForwardContAtt, def_k_caout, cAttentionOut.getOutputIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgument(def_k_FeedForwardContAtt, def_k_cadimension, int(iDimension))) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgument(def_k_FeedForwardContAtt, def_k_caheads, int(iHeads))) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; }
准备工作完成后,我们将内核放入执行队列之中。
if(!OpenCL.Execute(def_k_FeedForwardContAtt, 3, global_work_offset, global_work_size, local_work_size)) { printf("Error of execution kernel %s: %d", __FUNCTION__, GetLastError()); return false; } } //--- return true; }
不过,调用 2 个内核仅实现了所提议构象方法的一部分。这是主要的连续关注度部分。我们将在 CNeuronConformer::feedForward 方法中描述前馈验算的完整算法。与之前已创建类的相关方法类似,feedForward 方法在参数中接收指向前一层对象的指针,其中包含我们类的输入数据。
bool CNeuronConformer::feedForward(CNeuronBaseOCL *NeuronOCL) { //--- Generate Query, Key, Value if(!cQKV.FeedForward(NeuronOCL)) return false;
在方法主体中,我们首先调用内层 cQKV 的前馈方法,形成 Query、Key 和 Value 实体张量。之后,我们调用上面创建的方法来调用连续关注度机制的内核。
//--- MH Continuas Attention if(!attentionOut()) return false;
然后,我们降低获得的多头关注度结果的维度。结果张量会被加到输入数据当中,并在独立变量内进行常规化。
if(!cW0.FeedForward(GetPointer(cAttentionOut))) return false; if(!SumAndNormilize(NeuronOCL.getOutput(), cW0.getOutput(), cW0.getOutput(), iDimension, true, 0, 0, 0, 1)) return false;
根据构象算法,连续关注度模块后随一个常微分方程求解器模块。我们在循环中实现调用它们。之后,我们在输入和输出处累加张量,并对结果进行常规化。
//--- Neural ODE CNeuronBaseOCL *prev = GetPointer(cW0); for(int i = 0; i < 3; i++) { if(!cNODE[i].FeedForward(prev)) return false; prev = GetPointer(cNODE[i]); } if(!SumAndNormilize(prev.getOutput(), cW0.getOutput(), prev.getOutput(), iDimension, true, 0, 0, 0, 1)) return false;
在前馈方法结束时,我们执行 FeedForward 模块的前馈验算,然后对结果求,并常规化。
//--- Feed Forward for(int i = 0; i < 2; i++) { if(!cFF[i].FeedForward(prev)) return false; prev = GetPointer(cFF[i]); } if(!SumAndNormilize(prev.getOutput(), cNODE[2].getOutput(), getOutput(), iDimension, true, 0, 0, 0, 1)) return false; //--- return true; }
这样就完成了我们实现前馈算法的工作。但要训练模型,我们还需要实现反向传播验算,根据误差梯度对最终结果的影响,将误差梯度传播到所有元素,并调整模型参数,以便降低模型的整体误差。
2.3规划反向传播验算
为了实现反向传播算法,我们还需要创建新的内核。首先,我们需要创建一个内核,通过连续关注度 - HiddenGradientContAtt 模块传播误差梯度。在内核参数中,我们传递指向 6 个数据缓冲区和 1 个常量的指针。
__kernel void HiddenGradientContAtt(__global float *qkv, __global float *qkv_g, __global float *dqkv, __global float *dqkv_g, __global float *score, __global float *out_g, int dimension) { const size_t pos = get_global_id(0); const size_t variable = get_global_id(1); const size_t head = get_global_id(2); const size_t total = get_global_size(0); const size_t variables = get_global_size(1); const size_t heads = get_global_size(2);
类似于前馈内核,我们在 3-维任务空间中实现反向传播验算,但没有分组到工作组中。在内核主体中,我们在任务空间的所有维度中标识线程。
进一步的核算法根据误差梯度对象可以分为 3 个部分。在第一个数据模块中,我们将误差梯度分派到 Value 实体。
//--- Value gradient { const int shift_value = dimension * (heads * (3 * variables * pos + 3 * variable + 2) + head); const int shift_out = dimension * (head + variable * heads); const int shift_score = total * (variable * heads + head); const int step_out = variables * heads * dimension; const int step_score = variables * heads * total; //--- for(int d = 0; d < dimension; d++) { float sum = 0; for(int g = 0; g < total; g++) sum += out_g[shift_out + g * step_out + d] * score[shift_score + g * step_score]; qkv_g[shift_value + d] = sum; } }
此处,我们首先判定所需元素在数据缓冲区的偏移。然后,在循环系统中,我们收集所有依赖元素和实体向量的所有元素中的误差梯度。
在第二个模块中,我们将误差梯度传播到 Query。不过,此处的算法稍微复杂一些。
//--- Query gradient { const int shift_out = dimension * (heads * (pos * variables + variable) + head); const int step = 3 * variables * heads * dimension; const int shift_query = dimension * (3 * heads * variable + head) + pos * step; const int shift_key = dimension * (heads * (3 * variable + 1) + head); const int shift_value = dimension * (heads * (3 * variable + 2) + head); const int shift_score = total * (heads * (pos * variables + variable) + head);
如同第一个模块,我们首先判定欲分析元素在数据缓冲区中的偏移。之后,我们首先必须将梯度分派到依赖系数矩阵上,并根据 SoftMax 函数的导数进行调整。
//--- Score gradient for(int k = 0; k < total; k++) { float score_grad = 0; float scr = score[shift_score + k]; for(int v = 0; v < total; v++) { float grad = 0; for(int d = 0; d < dimension; d++) grad += qkv[shift_value + v * step + d] * out_g[shift_out + d]; score_grad += score[shift_score + v] * grad * ((float)(pos == v) - scr); } score_grad /= sqrt((float)dimension);
只有这样,我们才能将误差梯度传播到 Query 实体。不过,与原生变换器算法不同,在这种情况下,我们还将随时间的误差梯度传播到 Query 实体的相应导数。
//--- Query gradient for(int d = 0; d < dimension; d++) { if(k == 0) { dqkv_g[shift_query + d] = score_grad * qkv[shift_key + k * step + d]; qkv_g[shift_query + d] = score_grad * dqkv[shift_key + k * step + d]; } else { dqkv_g[shift_query + d] += score_grad * qkv[shift_key + k * step + d]; qkv_g[shift_query + d] += score_grad * dqkv[shift_key + k * step + d]; } } } }
以类似的方式运作,把误差梯度传播到 Key 实体,及其偏导数。但在依赖系数矩阵中,我们沿另一个维度传递。
//--- Key gradient { const int shift_key = dimension * (heads * (3 * variables * pos + 3 * variable + 1) + head); const int shift_out = dimension * (heads * variable + head); const int step_out = variables * heads * dimension; const int step = 3 * variables * heads * dimension; const int shift_query = dimension * (3 * heads * variable + head); const int shift_value = dimension * (heads * (3 * variable + 2) + head) + pos * step; const int shift_score = total * (heads * variable + head); const int step_score = variables * heads * total; //--- Score gradient for(int q = 0; q < total; q++) { float score_grad = 0; float scr = score[shift_score + q * step_score]; for(int g = 0; g < total; g++) { float grad = 0; for(int d = 0; d < dimension; d++) grad += qkv[shift_value + d] * out_g[shift_out + d + g * step_out]; score_grad += score[shift_score + q * step_score + g] * grad * ((float)(q == pos) - scr); } score_grad /= sqrt((float)dimension); //--- Key gradient for(int d = 0; d < dimension; d++) { if(q == 0) { dqkv_g[shift_key + d] = score_grad * qkv[shift_query + q * step + d]; qkv_g[shift_key + d] = score_grad * dqkv[shift_query + q * step + d]; } else { qkv_g[shift_key + d] += score_grad * dqkv[shift_query + q * step + d]; dqkv_g[shift_key + d] += score_grad * qkv[shift_query + q * step + d]; } } } } }
如您所见,在之前的内核中,我们将误差梯度传播到实体本身及其导数。我要提醒您,我们根据实体它们自身的值,针对环境的各种状态,随时间分析计算偏导数。逻辑上,我们可以按类似的途径传播误差梯度。我们在 HiddenGradientTimeDerivative 内核中实现了这样的算法。
__kernel void HiddenGradientTimeDerivative(__global float *qkv_g, __global float *dqkv_g, int dimension) { const size_t pos = get_global_id(0); const size_t variable = get_global_id(1); const size_t head = get_global_id(2); const size_t total = get_global_size(0); const size_t variables = get_global_size(1); const size_t heads = get_global_size(2);
内核参数和任务空间类似于前馈验算。我们仅用误差梯度缓冲区来取代结果缓冲区。
在方法的主体中,我们识别所用任务空间的所有维度中的线程。之后,我们判定在数据缓冲区中的偏移。
const int shift = 3 * heads * variables * dimension; const int shift_query = pos * shift + (3 * variable * heads + head) * dimension; const int shift_key = shift_query + heads * dimension;
与计算导数类似,我们实现了误差梯度的分派。
for(int i = 0; i < dimension; i++) { //--- dQ/dt { int count = 0; float grad = 0; float current = dqkv_g[shift_query + i]; if(pos > 0) { grad += current - dqkv_g[shift_query + i - shift]; count++; } if(pos < (total - 1)) { grad += dqkv_g[shift_query + i + shift] - current; count++; } if(count > 0) grad /= count; qkv_g[shift_query + i] += grad; }
//--- dK/dt { int count = 0; float grad = 0; float current = dqkv_g[shift_key + i]; if(pos > 0) { grad += current - dqkv_g[shift_key + i - shift]; count++; } if(pos < (total - 1)) { grad += dqkv_g[shift_key + i + shift] - current; count++; } if(count > 0) grad /= count; qkv_g[shift_key + i] += dqkv_g[shift_key + i] + grad; } } }
在主程序端于 CNeuronConformer::AttentionInsideGradients 方法中执行调用这些内核。构造该方法的算法类似于相应的前馈验算方法。仅以逆序调用内核。首先,我们经由连续关注度模块将梯度传播内核的执行排入队列。
bool CNeuronConformer::AttentionInsideGradients(void) { if(!OpenCL) return false; //--- MH Attention Out Gradient { uint global_work_offset[3] = {0, 0, 0}; uint global_work_size[3] = {iCount, iVariables, iHeads};
ResetLastError(); if(!OpenCL.SetArgumentBuffer(def_k_HiddenGradientContAtt, def_k_hgcaqkv, cQKV.getOutputIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgumentBuffer(def_k_HiddenGradientContAtt, def_k_hgcaqkv_g, cQKV.getGradientIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgumentBuffer(def_k_HiddenGradientContAtt, def_k_hgcadqkv, cdQKV.getOutputIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgumentBuffer(def_k_HiddenGradientContAtt, def_k_hgcadqkv_g, cdQKV.getGradientIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgumentBuffer(def_k_HiddenGradientContAtt, def_k_hgcascore, iScore)) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgumentBuffer(def_k_HiddenGradientContAtt, def_k_hgcaout_g, cAttentionOut.getGradientIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgument(def_k_HiddenGradientContAtt, def_k_hgcadimension, int(iDimension))) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; }
if(!OpenCL.Execute(def_k_HiddenGradientContAtt, 3, global_work_offset, global_work_size)) { printf("Error of execution kernel %s: %d", __FUNCTION__, GetLastError()); return false; } }
然后我们添加来自偏导数的误差梯度。
//--- Time Derivative Gradient { uint global_work_offset[3] = {0, 0, 0}; uint global_work_size[3] = {iCount, iVariables, iHeads};
ResetLastError(); if(!OpenCL.SetArgumentBuffer(def_k_HGTimeDerivative, def_k_tdqkv, cQKV.getGradientIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgumentBuffer(def_k_HGTimeDerivative, def_k_tddqkv, cdQKV.getGradientIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgument(def_k_HGTimeDerivative, def_k_tddimension, int(iDimension))) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; }
if(!OpenCL.Execute(def_k_HGTimeDerivative, 3, global_work_offset, global_work_size)) { printf("Error of execution kernel %s: %d", __FUNCTION__, GetLastError()); return false; } } //--- return true; }
准备工作完成后,我们在 CNeuronConformer::calcInputGradients 方法中汇编了整个误差梯度分派算法。在其参数中,我们接收指向上一层对象的指针。我们需要将误差梯度传递到它所指的层。
bool CNeuronConformer::calcInputGradients(CNeuronBaseOCL *prevLayer) { //--- Feed Forward Gradient if(!cFF[1].calcInputGradients(GetPointer(cFF[0]))) return false; if(!cFF[0].calcInputGradients(GetPointer(cNODE[2]))) return false; if(!SumAndNormilize(Gradient, cNODE[2].getGradient(), cNODE[2].getGradient(), iDimension, false)) return false;
幸好我们安排了梯度缓冲区交换,下一层将误差梯度直接传递到 FeedForward 模块中最后一个内层的缓冲区之中。故此,现在无需不必要的复制操作,我们按顺序调用前馈模块对象的反向传播验算方法。
在前馈验算期间,我们把前馈模块的输入和输出处的缓冲区数值相加。类似地,我们对累加误差梯度。然后我们将得到的结果传递给拥有常微分方程层的输出模块。之后,我们运行逆向循环遍历 神经 ODE 模块的内层,并在它们中传播误差梯度。
//--- Neural ODE Gradient CNeuronBaseOCL *prev = GetPointer(cNODE[1]); for(int i = 2; i > 0; i--) { if(!cNODE[i].calcInputGradients(prev)) return false; prev = GetPointer(cNODE[i - 1]); } if(!cNODE[0].calcInputGradients(GetPointer(cW0))) return false; if(!SumAndNormilize(cW0.getGradient(), cNODE[2].getGradient(), cW0.getGradient(), iDimension, false)) return false;
此处,我们还对模块输入和输出处的误差梯度求和。
前馈验算中的第一个,和反向传播验算中的最后一个是连续关注度。我们首先在关注度头之间分派误差梯度。
//--- MH Attention Gradient if(!cW0.calcInputGradients(GetPointer(cAttentionOut))) return false;
然后,我们通过关注度模块分派误差梯度。
if(!AttentionInsideGradients()) return false;
然后将误差梯度传播回前一层的级别。
//--- Query, Key, Value Graddients if(!cQKV.calcInputGradients(prevLayer)) return false;
在方法的最后,我们对关注度模块输入和输出的误差梯度求和。
if(!SumAndNormilize(cW0.getGradient(), prevLayer.getGradient(), prevLayer.getGradient(), iDimension, false)) return false; //--- return true; }
根据它们对最终结果的影响,在所有对象之间分派误差梯度之后,我们继续优化参数,以从而减低模型的整体误差。
此处应提到的是,我们的 CNeuronConformer 类,其所有学习参数都包含在内部神经层之中。因此,为了更新模型参数,我们仅需逐个调用内部对象的同名方法即可。
bool CNeuronConformer::updateInputWeights(CNeuronBaseOCL *NeuronOCL) { //--- MH Attention if(!cQKV.UpdateInputWeights(NeuronOCL)) return false; if(!cW0.UpdateInputWeights(GetPointer(cAttentionOut))) return false;
//--- Neural ODE CNeuronBaseOCL *prev = GetPointer(cW0); for(int i = 0; i < 3; i++) { if(!cNODE[i].UpdateInputWeights(prev)) return false; prev = GetPointer(cNODE[i]); }
//--- Feed Forward for(int i = 0; i < 2; i++) { if(!cFF[i].UpdateInputWeights(prev)) return false; prev = GetPointer(cFF[i]); } //--- return true; }
至此,我们总结了新的 CNeuronConformer 类方法解释,其中我们实现了构象方法的作者提议的主要方式。不幸的是,文章格式不允许我们更详地介绍该类的辅助方法。您可用附件中提供的文件自行研究这些方法。附件还包含本文中用到的所有程序的完整代码。我们继续转进。
2.4训练用模型架构
在我们转入所训练模型的架构之前,我想提醒您,根据构象方法,我们应当按环境描述的各个参数执行分析。因此,在输入数据的初始处理期间,我们需要为每个所分析参数创建一个嵌入。
首先,我们来看看所分析数据的结构。
......... ......... sState.state[shift] = (float)(Rates[b].close - open); sState.state[shift + 1] = (float)(Rates[b].high - open); sState.state[shift + 2] = (float)(Rates[b].low - open); sState.state[shift + 3] = (float)(Rates[b].tick_volume / 1000.0f); sState.state[shift + 4] = rsi; sState.state[shift + 5] = cci; sState.state[shift + 6] = atr; sState.state[shift + 7] = macd; sState.state[shift + 8] = sign; ........ ........
在我的实现中,我如下拆分源数据:
- 最后一根烛条的描述(4 个元素)
- RSI(1 个元素)
- CCI(1 个元素)
- ATF(1 个元素)
- MACD(2 个元素)
这样划分只是我的愿景。您也许会选择不同划分。不过,它必须反映在经过训练的模型架构中。
CreateDescriptions 方法中描述了已训练模型的构架。在参数中,该方法接收 3 个指向动态数组的指针,以便传输 3 个模型的架构。
在方法主体中,我们首先检查接收到的指针,并在必要时创建新的动态数组对象。
bool CreateDescriptions(CArrayObj *encoder, CArrayObj *actor, CArrayObj *critic) { //--- CLayerDescription *descr; //--- if(!encoder) { encoder = new CArrayObj(); if(!encoder) return false; } if(!actor) { actor = new CArrayObj(); if(!actor) return false; } if(!critic) { critic = new CArrayObj(); if(!critic) 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 = MathMax(1000, GPTBars); descr.activation = None; descr.optimization = ADAM; if(!encoder.Add(descr)) { delete descr; return false; }
之后,我们根据上面介绍的结构创建当前状态参数的嵌入。
//--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronEmbeddingOCL; { int temp[] = {4, 1, 1, 1, 2}; ArrayCopy(descr.windows, temp); }
注意,在前面讨论的嵌入架构中,我们指定的窗口大小等于输入数据大小。以这种方式,我们创建了一个单独状态的嵌入。然而,在这种情况下,我们从分析最后一根柱线的描述开始,将参数划分到上面指定的模块之中。如果您分析超过 1 根柱线、或其它数据配置,则您应在所分析数据窗口的大小中反映这一点。
prev_count = descr.count = GPTBars; int prev_wout = descr.window_out = EmbeddingSize / 2; if(!encoder.Add(descr)) { delete descr; return false; }
后续卷积层完成生成原始数据嵌入的过程。
//--- layer 3 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConvOCL; descr.count = prev_count * 5; descr.step = descr.window = prev_wout; prev_wout = descr.window_out = EmbeddingSize; if(!encoder.Add(descr)) { delete descr; return false; }
我们将位置编码谐波添加到嵌入之中。
//--- layer 4 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronPEOCL; descr.count = prev_count; descr.window = prev_wout * 5; if(!encoder.Add(descr)) { delete descr; return false; }
在编码器模型的末尾,我们创建一个包含 5 个连续构象层的模块。我们如其它关注度层相同的方式指定层参数。在 desc.layers 中指示欲分析变量的数量。
for(int i = 0; i < 5; i++) { if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConformerOCL; descr.count = prev_count; descr.window = prev_wout; descr.step = 4; descr.window_out = EmbeddingSize; descr.layers = 5; if(!encoder.Add(descr)) { delete descr; return false; } }
如前,扮演者模型的核心是一个交叉关注度层,其估测当前账户状态,与从编码器接收的当前环境状态的压缩表达之间的依赖关系。
我们首先向模型提供账户状态的描述。
//--- Actor actor.Clear(); //--- Input layer if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; 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; }
添加一个包含 3 个交叉关注度层的模块。
//--- layer 2-4 for(int i = 0; i < 3; i++) { if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronCrossAttenOCL; { int temp[] = {1, GPTBars * 5}; ArrayCopy(descr.units, temp); } { int temp[] = {EmbeddingSize, EmbeddingSize}; ArrayCopy(descr.windows, temp); } descr.window_out = 16; descr.step = 4; descr.activation = None; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; } }
根据从交叉关注度模块获得的数据,我们形成扮演者的随机政策。
//--- layer 5 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 6 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 7 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronVAEOCL; descr.count = NActions; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; }
评论者模型按类似的结构搭建。但它不是账户状态,而是将扮演者的动作与环境状态进行比较。
我们将扮演者生成的动作馈送到模型之中。
//--- Critic critic.Clear(); //--- Input layer if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; prev_count = descr.count = NActions; descr.activation = None; descr.optimization = ADAM; if(!critic.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(!critic.Add(descr)) { delete descr; return false; }
接着来到的是 3 层交叉关注度模块。
//--- layer 2-4 for(int i = 0; i < 3; i++) { if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronCrossAttenOCL; { int temp[] = {1, GPTBars * 5}; ArrayCopy(descr.units, temp); } { int temp[] = {EmbeddingSize, EmbeddingSize}; ArrayCopy(descr.windows, temp); } descr.window_out = 16; descr.step = 4; descr.activation = None; descr.optimization = ADAM; if(!critic.Add(descr)) { delete descr; return false; } }
这些动作在感知器模块中进行评测。
//--- layer 5 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = LatentCount; descr.activation = SIGMOID; descr.optimization = ADAM; if(!critic.Add(descr)) { delete descr; return false; } //--- layer 6 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = LatentCount; descr.activation = SIGMOID; descr.optimization = ADAM; if(!critic.Add(descr)) { delete descr; return false; } //--- layer 7 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = NRewards; descr.activation = None; descr.optimization = ADAM; if(!critic.Add(descr)) { delete descr; return false; } //--- return true; }
2.5模型训练
我们所做的修改并不会影响与环境交互的过程。因此,我们能够不加修改地用 “...\Conformer\Research.mq5” EA 来收集初始训练数据,然后更新训练数据集。此外,尽管输入数据的分析方式有所变化,但数据结构保持不变。这允许我们采用之前收集的训练数据集来训练模型。
然而,我们在 “...\Conformer\Study.mq5” EA 的算法中针对模型训练过程进行了一些修改。在本文中,我们只研究模型训练方法 Train。
如前,在方法的开头,我们根据盈利能力生成一个选择轨迹的概率向量。在模型训练期间,盈利较高的验算被选中的概率更高。
void Train(void) { //--- vector<float> probability = GetProbTrajectories(Buffer, 0.9);
然后我们初始化局部变量。
vector<float> result, target; bool Stop = false; //--- uint ticks = GetTickCount();
创建嵌套模型训练循环系统。在外部循环的主体中,我们从经验回放缓冲区中对轨迹、及其初始训练状态进行采样。
int tr = SampleTrajectory(probability); int batch = GPTBars + 48; int state = (int)((MathRand() * MathRand() / MathPow(32767, 2)) * (Buffer[tr].Total - 2 - PrecoderBars - batch)); if(state <= 0) { iter--; continue; }
之后,我们清除编码器的循环缓冲区,并判定训练数据集的最终状态。
Encoder.Clear(); int end = MathMin(state + batch, Buffer[tr].Total - PrecoderBars);
准备工作完成后,我们规划一个遍历训练状态的嵌套循环。
for(int i = state; i < end; i++) { 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; }
在循环的主体中,我们首先从经验回放缓冲区加载环境的状态,然后调用前馈方法在编码器中对其进行分析。
接下来,我们从经验回放缓冲区加载扮演者的动作,并利用评论者对其进行评测。
//--- Critic bActions.AssignArray(Buffer[tr].States[i].action); if(bActions.GetIndex() >= 0) bActions.BufferWrite(); if(!Critic.feedForward((CBufferFloat*)GetPointer(bActions), 1, false, GetPointer(Encoder))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
然后,我们将评论者的评测调整为来自经验回放缓冲区的实际奖励。
result.Assign(Buffer[tr].States[i + 1].rewards); target.Assign(Buffer[tr].States[i + 2].rewards); result = result - target * DiscFactor; Result.AssignArray(result); Critic.TrainMode(true); if(!Critic.backProp(Result, (CNet *)GetPointer(Encoder)) || !Encoder.backPropGradient((CBufferFloat*)NULL)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
我们还将评论者误差梯度传递给编码器,以便分析环境的状态。
接下来,我们从经验回放缓冲区中加载与环境的分析状态对应的帐户状态的描述。
//--- Policy float PrevBalance = Buffer[tr].States[MathMax(i - 1, 0)].account[0]; float PrevEquity = Buffer[tr].States[MathMax(i - 1, 0)].account[1]; bAccount.Clear(); bAccount.Add((Buffer[tr].States[i].account[0] - PrevBalance) / PrevBalance); bAccount.Add(Buffer[tr].States[i].account[1] / PrevBalance); bAccount.Add((Buffer[tr].States[i].account[1] - PrevEquity) / PrevEquity); bAccount.Add(Buffer[tr].States[i].account[2]); bAccount.Add(Buffer[tr].States[i].account[3]); bAccount.Add(Buffer[tr].States[i].account[4] / PrevBalance); bAccount.Add(Buffer[tr].States[i].account[5] / PrevBalance); bAccount.Add(Buffer[tr].States[i].account[6] / PrevBalance); double time = (double)Buffer[tr].States[i].account[7]; double x = time / (double)(D'2024.01.01' - D'2023.01.01'); bAccount.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0)); x = time / (double)PeriodSeconds(PERIOD_MN1); bAccount.Add((float)MathCos(x != 0 ? 2.0 * M_PI * x : 0)); x = time / (double)PeriodSeconds(PERIOD_W1); bAccount.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0)); x = time / (double)PeriodSeconds(PERIOD_D1); bAccount.Add((float)MathSin(x != 0 ? 2.0 * M_PI * x : 0)); if(bAccount.GetIndex() >= 0) bAccount.BufferWrite();
基于这些数据,我们根据其当前策略生成扮演者动作。
//--- Actor if(!Actor.feedForward((CBufferFloat*)GetPointer(bAccount), 1, false, GetPointer(Encoder))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
然后,我们利用评论者评测这些动作。
if(!Critic.feedForward((CNet *)GetPointer(Actor), -1, (CNet*)GetPointer(Encoder))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
扮演者的政策略分 2 步调整。首先,我们调整政策,以便最大程度地减少与个体实际动作的偏差。这允许我们将扮演者的政策保持在靠近训练集的分布当中。
if(!Actor.backProp(GetPointer(bActions), GetPointer(Encoder)) || !Encoder.backPropGradient((CBufferFloat*)NULL)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
在第二步中,我们根据评论者对其行为的评测来调整扮演者的政策。为此,我们禁用评论者的训练模式,并通过它将误差梯度传播到扮演者。之后,我们根据获得的误差梯度调整政策。
Critic.TrainMode(false); if(!Critic.backProp(Result, (CNet *)GetPointer(Encoder)) || !Actor.backPropGradient((CNet *)GetPointer(Encoder), -1, -1, false) || !Encoder.backPropGradient((CBufferFloat*)NULL)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
注意,在两种调整扮演者政策的情况下,我们都会将误差梯度传播到编码器,并调整环境的“视野”。以这种方式,我们努力把环境分析的信息量最大化。
所有模型参数更新完毕之后,我们仅需通知用户训练过程的进度,然后转入循环系统的下一次迭代。
if(GetTickCount() - ticks > 500) { double percent = (double(i - state) / ((end - state)) + iter) * 100.0 / (Iterations); string str = StringFormat("%-14s %6.2f%% -> Error %15.8f\n", "Actor", percent, Actor.getRecentAverageError()); str += StringFormat("%-14s %6.2f%% -> Error %15.8f\n", "Critic", percent, Critic.getRecentAverageError()); Comment(str); ticks = GetTickCount(); } } }
重复训练过程,直到循环系统的所有迭代都完全耗尽。训练过程成功完成后,我们将清除图表上的注释字段。
Comment(""); //--- PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Actor", Actor.getRecentAverageError()); PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Critic", Critic.getRecentAverageError()); ExpertRemove(); //--- }
我们将模型训练过程的结果输出到日志中,并初始化训练 EA 的终止。
我们针对程序文章中所用算法的分析到此结束。您可以在附件中找到完整的代码。
3. 测试
在本文中,我们讨论了构象方法,并利用 MQL5 实现了所提议的方式。现在我们有机会运用所提议方法训练模型,并依据真实数据对其进行测试。
如常,我们将在 MetaTrader 5 策略测试器中依据真实历史 EURUSD、H1 数据训练和测试模型。为了训练模型,我们采用 2023 年前 7 个月的历史数据。然后,依据 2023 年 8 月的历史数据测试已训练模型。
在准备本文时,我采用本系列前几篇文章中为训练模型而收集的样本训练了模型。
我必须说,模型架构和训练过程算法的变化,导致了每次迭代的成本略有增加。然而,所提议方法证明了学习过程的稳定性,我认为这降低了训练模型所需的迭代次数。
在训练期间,我获得了一个模型,能够在训练和测试数据集上均产生利润。
在测试期间,该模型执行了 34 笔交易,其中 18 笔以盈利平仓。这做到了 52.94% 的盈利交易。进而,平均盈利交易比平均亏损交易高 52.47%。最大盈利比相同的亏损高 2 倍以上。总体而言,该模型展示的盈利因子为 1.72,余额图展现出上升趋势。最高净值回撤为 17.12%,余额回撤为 8.96%。
结束语
在本文中,我们学习了一种复杂的构象算法“时空常数关注度变换器”,该算法是为天气预报目的而开发的,最初在论文《构象:在视觉变换器中嵌入持续注意力进行天气预报》中提出。该方法的作者提议连续关注度算法,并将其与神经 ODE 相结合。
在本文的实践部分,我们利用 MQL5 实现了提议的方式。我们已训练和测试了创建的模型。测试结果相当有前景。该模型在训练和测试数据集上都产生了盈利。
不过,我想提醒您,本文中讲述的所有程序仅供参考,旨在演示所提议的方法。
参考
文中所用程序
# | 名称 | 类型 | 说明 |
---|---|---|---|
1 | Research.mq5 | EA | 样本收集 EA |
2 | ResearchRealORL.mq5 | EA | 运用 Real-ORL 方法收集示例的 EA |
3 | Study.mq5 | EA | 模型训练 EA |
4 | Test.mq5 | EA | 模型测试 EA |
5 | Trajectory.mqh | 类库 | 系统状态定义结构 |
6 | NeuroNet.mqh | 类库 | 创建神经网络的类库 |
7 | NeuroNet.cl | 代码库 | OpenCL 程序代码库 |
本文由MetaQuotes Ltd译自俄文
原文地址: https://www.mql5.com/ru/articles/14615


