
神经网络变得简单(第 74 部分):自适应轨迹预测
概述
构建交易策略时,分析市场局面和预测金融产品最可能的走势密不可分。这种走势通常与其它金融资产和宏观经济指标相关。这可以与运输的动态进行比较,其中每辆车都遵循自己的独立目的地。不过,它们在道路上的动作在一定程度上是相互关联的,并受到交通规则的严格监管。还有,由于车辆驾驶员对道路状况的个人感知,在道路上仍然留有一部分随机性。
类似地,在金融领域,价格形成也受到某些规则的约束。然而,由市场参与者创造的供需随机性,导致了价格的随机性。这也许就是在预测未来价格走势方面,借助众多导航领域中所用的轨迹预测方法表现优良的原因。
在本文中,我想向您介绍一种通过权重动态学习 ADAPT 有效联合预测现场所有个体轨迹的方法,该方法是为了解决自动驾驶汽车导航领域的问题而提出的。该方法首次在文章 《ADAPT:自适应高效多个体轨迹预测》 中提出。
1. ADAPT 算法
ADAPT 方法分析场景地图中所有个体的过去轨迹,并预测它们的未来轨迹。矢量化场景表示对个体和地图之间不同类型的交互进行建模,从而获得个体的最佳可能表示。与目标设定方法类似,该算法首先预测一组可能的端点。然后,参考个体在场景中的位移,每个端点都会被优化。在此之后,预测在端点的完整轨迹。
该方法的作者在稳定模型训练时,是将端点和轨迹预测与梯度停止分开。由作者表述的模型,使用小型多层感知器来预测端点和轨迹,以便保持较低的模型复杂性。
作者所提议方法使用矢量化表示,以便按结构化方式对地图和个体进行编码。这种表示形式为每个场景元素独立创建一个连接的图形,给出个体和场景映射的过去轨迹。该方法的作者提议针对个体和映射对象使用两个单独的子图。
ADAPT 允许您模拟场景元素之间的各种类型的交互。作者提议对四种类型的关系进行建模:个体-对-通道(AL)、通道-对-通道(LL)、通道-对-个体(LA)、和个体-对-个体(AA)。
使用多目关注模块分析相互依赖关系,类似于 AutoBots。然而,自关注模块(AA,LL) 与使用交叉关注编码器的交叉关系模块(AL,LA)相辅相成。每个交互都按顺序建模,并且该过程重复 L 次。
按这种方式,在每次迭代时可以更新过渡特征,然后在下一次迭代时取更新的特征计算关注度。每个场景元素都可由不同类型的交互 L 次来通知。
为了在以个体为中心的表示形式的情况下预测端点,可以用 MLP,其在单个体预测方面的优势,也许其更可取。但是当以场景为中心的表示时,建议使用具有动态权重的自适应头部,其在多个体轨迹端点的预测中更有效果。
收到每个个体的端点后,该算法使用 MLP 在起点和终点之间插入未来坐标。此处,我们将端点“解耦”,以确保对应完整轨迹预测的权重更新与端点预测解耦。我们用类似的解耦端点方式来预测每条轨迹的概率。
为了训练模型,我们预测 K 条轨迹,并应用各种损失函数来捕捉多模态未来场景。误差梯度仅通过最精确的轨迹反向传播。由于我们以端点为条件预测完整轨迹,故端点预测的准确性对于完整轨迹预测至关重要。因此,该方法的作者应用了一个单独的损失函数来改进端点预测。原始损失函数的最后一个元素是分类损失,来指导分配给轨迹的概率。
由论文作者所陈述方法的原始可视化提供如下。
2. 利用 MQL5 实现
以上是对 ADAPT 方法的相当浓缩的理论描述,这是限于前面的工作量太大和文章格式。某些方面在我们实现所建议方法期间会更详细地讨论。请注意,我们的实现在许多方面与原始方法不同。以下是区别。
首先,我们不会使用单独的张量来为个体和折线编码。在我们的例子中,个体是所分析的特征。每个特征都有 2 个参数:数值和时间。在所分析时间区间内,它沿特定轨迹移动。虽然每个指标都有自己的数值范围,但我们实际上没有场景的地图。不过,我们有一个在单一时间点的场景快照,其中包含所有个体。技术上讲,我们可以将一个实体替换为另一个。似乎没有必要为此创建一个单独的张量,因为这是在另一个维度中查看相同的数据。因此,我们将使用一个具有不同重音的张量。
2.1交叉关系模块
进而,在思考实现所建议方法时,我意识到我还没有实现交叉关系模块。以前,我们的任务本质上更具有自回归性。对于此类任务,使用自关注模块就足矣了。这次我们需要分析各种实体之间的关系。故此,我们将实现一个新的神经层 CNeuronMH2AttentionOCL。该类实现的算法主要借鉴了自关注模块。区别在于查询、主键、和数值实体将依据源数据张量的不同维度形成。这需要进行大量修改。因此,我决定创建一个新类,而不是对已有的进行现代化改造。
class CNeuronMH2AttentionOCL : public CNeuronBaseOCL { protected: uint iHeads; ///< Number of heads uint iWindow; ///< Input window size uint iUnits; ///< Number of units uint iWindowKey; ///< Size of Key/Query window //--- CNeuronConvOCL Q_Embedding; CNeuronConvOCL KV_Embedding; CNeuronTransposeOCL Transpose; int ScoreIndex; CNeuronBaseOCL MHAttentionOut; CNeuronConvOCL W0; CNeuronBaseOCL AttentionOut; CNeuronConvOCL FF[2]; //--- virtual bool feedForward(CNeuronBaseOCL *NeuronOCL); virtual bool attentionOut(void); //--- virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL); virtual bool AttentionInsideGradients(void); public: /** Constructor */ CNeuronMH2AttentionOCL(void); /** Destructor */~CNeuronMH2AttentionOCL(void) {}; virtual bool Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint window_key, uint heads, uint units_count, ENUM_OPTIMIZATION optimization_type, uint batch); virtual bool calcInputGradients(CNeuronBaseOCL *prevLayer); //--- virtual int Type(void) const { return defNeuronMH2AttentionOCL; } //--- 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 *source, float tau); virtual void SetOpenCL(COpenCLMy *obj); };
在类构造函数中,我们只为局部变量设置初始值。
CNeuronMH2AttentionOCL::CNeuronMH2AttentionOCL(void) : iHeads(0), iWindow(0), iUnits(0), iWindowKey(0) { activation = None; }
类的析构函数保持为空。
CNeuronMH2AttentionOCL 类对象的初始化在 Init 方法中实现。在该方法的开头,我们调用父类的相关方法,其中检查从外部程序接收的数据,并初始化继承对象。
bool CNeuronMH2AttentionOCL::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint window_key, uint heads, uint units_count, ENUM_OPTIMIZATION optimization_type, uint batch) { if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, window * units_count, optimization_type, batch)) return false;
我们保存主要参数的值。
iWindow = fmax(window, 1); iWindowKey = fmax(window_key, 1); iUnits = fmax(units_count, 1); iHeads = fmax(heads, 1); activation = None;
由于我们将在不同维度上分析源数据,故我们需要转置源数据的张量。
if(!Transpose.Init(0, 0, OpenCL, iUnits, iWindow, optimization_type, batch)) return false; Transpose.SetActivationFunction(None);
为了生成 Query、Key 和 Value 实体,我们将使用卷积层。过滤器的数量等于一个实体的向量维度。Query 将从原始数据张量的一个维度生成,而 Key 和 Value 则从另外的维度生成。因此,我们将创建 2 个层(每个维度一个)。
if(!Q_Embedding.Init(0, 0, OpenCL, iWindow, iWindow, iWindowKey * iHeads, iUnits, optimization_type, batch)) return false; Q_Embedding.SetActivationFunction(None); if(!KV_Embedding.Init(0, 0, OpenCL, iUnits, iUnits, 2 * iWindowKey * iHeads, iWindow, optimization_type, batch)) return false; KV_Embedding.SetActivationFunction(None);
我们只需要 OpenCL 关联环境侧的依赖系数矩阵。为了节省所用资源,我们只在关联环境中创建一个缓冲区。在主程序的一侧,仅存储指向缓冲区的指针。
ScoreIndex = OpenCL.AddBuffer(sizeof(float) * iUnits * iWindow * iHeads, CL_MEM_READ_WRITE); if(ScoreIndex == INVALID_HANDLE) return false;
接下来是类似于自关注模块的对象。此处我们创建了一个多目关注输出层。
//--- if(!MHAttentionOut.Init(0, 0, OpenCL, iWindowKey * iUnits * iHeads, optimization_type, batch)) return false; MHAttentionOut.SetActivationFunction(None);
降维层。
if(!W0.Init(0, 0, OpenCL, iWindowKey * iHeads, iWindowKey * iHeads, iWindow, iUnits, optimization_type, batch)) return false; W0.SetActivationFunction(None);
在关注模块的输出中,我们在一个单独的层中将得自原始数据的结果汇总 。
if(!AttentionOut.Init(0, 0, OpenCL, iWindow * iUnits, optimization_type, batch)) return false; AttentionOut.SetActivationFunction(None);
它后随一个线性 MLP 模块。
if(!FF[0].Init(0, 0, OpenCL, iWindow, iWindow, 4 * iWindow, iUnits, optimization_type, batch)) return false; if(!FF[1].Init(0, 0, OpenCL, 4 * iWindow, 4 * iWindow, iWindow, iUnits, optimization_type, batch)) return false; for(int i = 0; i < 2; i++) FF[i].SetActivationFunction(None);
为了在反向传播过程中避免不必要地将误差梯度从父类缓冲区复制到内层缓冲区,我们将替换指向对象的指针。
Gradient.BufferFree(); delete Gradient; Gradient = FF[1].getGradient(); //--- return true; }
转入前馈验算的描述,请注意,尽管有大量内层实现了某些功能,但我们需要直接分析关系。尽管从数学上讲,该功能与自关注模块完全相同,但我们所面临的事实是查询实体的数量很可能与主键和数值实体的数量不同,这会影响矩形 Score 矩阵,并违反先前所创建内核的逻辑。因此,我们要创建新的内核。
对于前馈验算,我们创建 MH2AttentionOut 内核。内核将在参数中接收 4 个指针,分别指向数据缓冲区,和一个实体元素的向量维度。我们所有的实体都有相同大小的元素。
__kernel void MH2AttentionOut(__global float *q, ///<[in] Matrix of Querys __global float *kv, ///<[in] Matrix of Keys __global float *score, ///<[out] Matrix of Scores __global float *out, ///<[out] Matrix of Scores int dimension ///< Dimension of Key ) { //--- init const int q_id = get_global_id(0); const int k = get_global_id(1); const int h = get_global_id(2); const int qunits = get_global_size(0); const int kunits = get_global_size(1); const int heads = get_global_size(2);
我们将在任务空间中启动的内核,与 3 个维度的元素持平,Query、Key、和关注眼目。此外,一个 Query 元素和一个关注眼目中的所有线程将被合并为一组,这是因为需要在指定组内调用 SoftMax 函数对 Score 矩阵进行常规化。
在内核主体中,我们首先标识每个线程,并判定全局数据缓冲区中的偏移量。
const int shift_q = dimension * (q_id + qunits * h); const int shift_k = dimension * (k + kunits * h); const int shift_v = dimension * (k + kunits * (heads + h)); const int shift_s = q_id * kunits * heads + h * kunits + k;
我们还定义了其它常量,并声明了一个局部数组。
const uint ls = min((uint)get_local_size(1), (uint)LOCAL_ARRAY_SIZE); float koef = sqrt((float)dimension); if(koef < 1) koef = 1; __local float temp[LOCAL_ARRAY_SIZE];
之后我们计算依赖系数矩阵。
//--- sum of exp uint count = 0; if(k < ls) do { if((count * ls) < (kunits - k)) { float sum = 0; for(int d = 0; d < dimension; d++) sum = q[shift_q + d] * kv[shift_k + d]; sum = exp(sum / koef); if(isnan(sum)) sum = 0; temp[k] = (count > 0 ? temp[k] : 0) + sum; } count++; } while((count * ls + k) < kunits); barrier(CLK_LOCAL_MEM_FENCE);
count = min(ls, (uint)kunits); //--- do { count = (count + 1) / 2; if(k < ls) temp[k] += (k < count && (k + count) < kunits ? temp[k + count] : 0); if(k + count < ls) temp[k + count] = 0; barrier(CLK_LOCAL_MEM_FENCE); } while(count > 1);
//--- score float sum = temp[0]; float sc = 0; if(sum != 0) { for(int d = 0; d < dimension; d++) sc = q[shift_q + d] * kv[shift_k + d]; sc = exp(sc / koef); if(isnan(sc)) sc = 0; } score[shift_s] = sc; barrier(CLK_LOCAL_MEM_FENCE);
我们还计算 Query 实体的新值,分别参考向量里每个元素的依赖系数。
//--- out for(int d = 0; d < dimension; d++) { uint count = 0; if(k < ls) do { if((count * ls) < (kunits - k)) { float sum = q[shift_q + d] * kv[shift_v + d] * (count == 0 ? sc : score[shift_s + count * ls]); if(isnan(sum)) sum = 0; temp[k] = (count > 0 ? temp[k] : 0) + sum; } count++; } while((count * ls + k) < kunits); barrier(CLK_LOCAL_MEM_FENCE); //--- count = min(ls, (uint)kunits); do { count = (count + 1) / 2; if(k < ls) temp[k] += (k < count && (k + count) < kunits ? temp[k + count] : 0); if(k + count < ls) temp[k + count] = 0; barrier(CLK_LOCAL_MEM_FENCE); } while(count > 1); //--- out[shift_q + d] = temp[0]; } }
接下来,我们创建一个新的内核 MH2AttentionInsideGradients,来实现反向传播功能。我们还将在 3 维任务空间中运行这个内核。
在内核参数中,我们将传递 6 个指向数据缓冲区的指针。其中包括所有实体的误差梯度缓冲区。
__kernel void MH2AttentionInsideGradients(__global float *q, __global float *q_g, __global float *kv, __global float *kv_g, __global float *scores, __global float *gradient, int kunits) { //--- init const int q_id = get_global_id(0); const int d = get_global_id(1); const int h = get_global_id(2); const int qunits = get_global_size(0); const int dimension = get_global_size(1); const int heads = get_global_size(2);
在内核主体中,我们一如既往地标识线程,并创建必要的常量。
const int shift_q = dimension * (q_id + qunits * h) + d; const int shift_k = dimension * (q_id + kunits * h) + d; const int shift_v = dimension * (q_id + kunits * (heads + h)) + d; const int shift_s = q_id * kunits * heads + h * kunits; const int shift_g = h * qunits * dimension + d; float koef = sqrt((float)dimension); if(koef < 1) koef = 1;
首先,我们计算 Value 实体的误差梯度。为此,我们只需将关注模块输出的误差梯度向量与相应的依赖系数相乘。
//--- Calculating Value's gradients int step_score = q_id * kunits * heads; for(int v = q_id; v < kunits; v += qunits) { int shift_score = h * kunits + v; float grad = 0; for(int g = 0; g < qunits; g++) grad += gradient[shift_g + g * dimension] * scores[shift_score + g * step_score]; kv_g[shift_v + v * dimension]=grad; }
然后,我们计算 Query 实体的误差梯度。这次我们首先需要计算依赖系数矩阵元素的误差梯度,同时参考 SoftMax 函数的导数。然后,它应该乘以 Key 张量的相应元素。
//--- Calculating Query's gradients float grad = 0; float out_g = gradient[shift_g + q_id * dimension]; int shift_val = (heads + h) * kunits * dimension + d; int shift_key = h * kunits * dimension + d; for(int k = 0; k < kunits; k++) { float sc_g = 0; float sc = scores[shift_s + k]; for(int v = 0; v < kunits; v++) sc_g += scores[shift_s + v] * out_g * kv[shift_val + v * dimension] * ((float)(k == v) - sc); grad += sc_g * kv[shift_key + k * dimension]; } q_g[shift_q] = grad / koef;
类似地,我们计算 Key 实体的误差梯度。然而,这次我们是计算依赖系数沿相应张量列的误差梯度。
//--- Calculating Key's gradients for(int k = q_id; k < kunits; k += qunits) { int shift_score = h * kunits + k; int shift_val = (heads + h) * kunits * dimension + d; grad = 0; float val = kv[shift_v]; for(int scr = 0; scr < qunits; scr++) { float sc_g = 0; int shift_sc = scr * kunits * heads; float sc = scores[shift_sc + k]; for(int v = 0; v < kunits; v++) sc_g += scores[shift_sc + v] * gradient[shift_g + scr * dimension] * val * ((float)(k == v) - sc); grad += sc_g * q[shift_q + scr * dimension]; } kv_g[shift_k + k * dimension] = grad / koef; } }
依据 OpenCL 关联环境端构建算法后,我们回到我们的类,在主程序端组织流程。首先,我们看看 feedForward 方法。与其它神经层的相关方法类似,在参数中,我们接收一个指向前一个神经层的指针,由其提供源数据。
bool CNeuronMH2AttentionOCL::feedForward(CNeuronBaseOCL *NeuronOCL) { //--- if(!Q_Embedding.FeedForward(NeuronOCL)) return false;
不过,我们不会检查所接收指针的相关性。取而代之,我们调用 Q_Embedding 内层的前馈方法来创建一个 Query 实体的张量,并将结果指针传递给它。在指定方法的主体中,所有必要的控制都已实现,故我们无需再次实现它们。
接下来,我们将生成 Key 和 Value 实体。如前所述,对于这些,我们用到原始数据张量的不同维度。因此,我们首先转置源数据矩阵,然后调用对应内层的前馈方法。
if(!Transpose.FeedForward(NeuronOCL) || !KV_Embedding.FeedForward(NeuronOCL)) return false;
MH2AttentionOut 内核调用将在单独的方法 attentionOut 中实现。
if(!attentionOut()) return false;
我们将多目关注结果张量压缩为原始数据的大小。
if(!W0.FeedForward(GetPointer(MHAttentionOut))) return false;
然后我们将所得数值添加到原始数据中,并对其进行常规化。SumAndNormilize 方法是从父类继承而来。
//--- if(!SumAndNormilize(W0.getOutput(), NeuronOCL.getOutput(), AttentionOut.getOutput(), iWindow)) return false;
在关注模块的末尾,我们通过 MLP 传递数据。
if(!FF[0].FeedForward(GetPointer(AttentionOut))) return false; if(!FF[1].FeedForward(GetPointer(FF[0]))) return false;
再次添加数值,并常规化。
if(!SumAndNormilize(FF[1].getOutput(), AttentionOut.getOutput(), Output, iWindow)) return false; //--- return true; }
为了完成前馈算法的图景,我们来研究一下 attentionOut 方法。该方法不接收参数,并且仅适用于内部类对象。因此,在方法的主体中,我们只检查指针与 OpenCL 关联环境的相关性。
bool CNeuronMH2AttentionOCL::attentionOut(void) { if(!OpenCL) return false;
接下来,我们将创建任务空间和偏移量数组。正如在构建内核时所讨论的,我们创建了一个 3-维问题空间,其中沿第二个维度带有一个局部组。
uint global_work_offset[3] = {0}; uint global_work_size[3] = {iUnits, iWindow, iHeads}; uint local_work_size[3] = {1, iWindow, 1};
我们将必要的参数传递给内核。
ResetLastError(); if(!OpenCL.SetArgumentBuffer(def_k_MH2AttentionOut, def_k_mh2ao_q, Q_Embedding.getOutputIndex())) { 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_Embedding.getOutputIndex())) { 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, ScoreIndex)) { 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, MHAttentionOut.getOutputIndex())) { 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.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; }
我们已经在主程序端和 OpenCL 关联环境端实现了前馈验算流程。接下来,我们需要安排反向传播过程。为了在 OpenCL 关联环境侧实现算法,我们已经创建了 MH2AttentionInsideGradients 内核。现在我们需要创建 AttentionInsideGradients 方法来调用这个内核。我们不会在参数中传递任何内容至该方法,类似于相关的前馈方法。
bool CNeuronMH2AttentionOCL::AttentionInsideGradients(void) { if(!OpenCL) return false;
在方法的主体中,我们检查指针与 OpenCL 关联环境的相关性。此后,我们创建数组,指示任务空间的维度和其内的偏移量。
uint global_work_offset[3] = {0}; uint global_work_size[3] = {iUnits, iWindowKey, iHeads};
将必要的参数传递给内核。
ResetLastError(); if(!OpenCL.SetArgumentBuffer(def_k_MH2AttentionInsideGradients, def_k_mh2aig_q, Q_Embedding.getOutputIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgumentBuffer(def_k_MH2AttentionInsideGradients, def_k_mh2aig_qg, Q_Embedding.getGradientIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgumentBuffer(def_k_MH2AttentionInsideGradients, def_k_mh2aig_kv, KV_Embedding.getOutputIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgumentBuffer(def_k_MH2AttentionInsideGradients, def_k_mh2aig_kvg, KV_Embedding.getGradientIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgumentBuffer(def_k_MH2AttentionInsideGradients, def_k_mh2aig_score, ScoreIndex)) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgumentBuffer(def_k_MH2AttentionInsideGradients, def_k_mh2aig_outg, MHAttentionOut.getGradientIndex())) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; } if(!OpenCL.SetArgument(def_k_MH2AttentionInsideGradients, def_k_mh2aig_kunits, (int)iWindow)) { printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__, GetLastError(), __LINE__); return false; }
将内核放入执行队列当中。
if(!OpenCL.Execute(def_k_MH2AttentionInsideGradients, 3, global_work_offset, global_work_size)) { printf("Error of execution kernel %s: %d", __FUNCTION__, GetLastError()); return false; } //--- return true; }
通常,这是该类任务的标准算法。在我们的层内分配误差梯度的整个算法由 calcInputGradients 方法描述。在参数中,该方法接收一个指针,其指向必须将误差梯度传递到的上一层对象。
bool CNeuronMH2AttentionOCL::calcInputGradients(CNeuronBaseOCL *prevLayer) { if(!FF[1].calcInputGradients(GetPointer(FF[0]))) return false;
在方法的主体中,我们交替地将误差梯度从模块输出传播到前一层。如您所记,在初始化类时,我们将指针替换为误差梯度缓冲区。随后的层将误差梯度直接写入内部 MLP 的最后一层。从那里,我们将误差梯度传播到关注模块的输出级。
if(!FF[0].calcInputGradients(GetPointer(AttentionOut))) return false;
在这一级,我们将关注模块的结果添加到初始数据之中。类似地,我们从 2 个方向收集梯度。
if(!SumAndNormilize(FF[1].getGradient(), AttentionOut.getGradient(), W0.getGradient(), iWindow, false)) return false;
接下来,我们将误差梯度传播到关注度的眼目。
if(!W0.calcInputGradients(GetPointer(MHAttentionOut))) return false;
将误差梯度传播到实体。
if(!AttentionInsideGradients()) return false;
我们将误差梯度从 Key 和 Value 传播到转置层。在前馈验算中,我们转置了源数据矩阵。配以误差梯度,我们必须执行相反的操作。
if(!KV_Embedding.calcInputGradients(GetPointer(Transpose))) return false;
接下来,我们必须将所有实体的误差梯度传递到上一层。
if(!Q_Embedding.calcInputGradients(prevLayer)) return false;
请注意此处,误差梯度自 4 个线程转到上一层:
- 查询(Query)
- 主键(Key)
- 数值(Value)
- 绕过关注模块。
不过,我们的内层方法在传递误差梯度时,会删除以前记录的数据。因此,收到来自 Query 的误差梯度后,我们将其添加到内层缓冲区中关注模块输出的误差梯度当中。
if(!SumAndNormilize(prevLayer.getGradient(), W0.getGradient(), AttentionOut.getGradient(), iWindow, false)) return false;
从 Key 和 Value 收到数据后,我们将所有线程相加。
if(!Transpose.calcInputGradients(prevLayer)) return false; if(!SumAndNormilize(prevLayer.getGradient(), AttentionOut.getGradient(), prevLayer.getGradient(), iWindow, false)) return false; //--- return true; }
权重更新方法非常简单。我们只需调用内层的相关方法。
bool CNeuronMH2AttentionOCL::updateInputWeights(CNeuronBaseOCL *NeuronOCL) { if(!Q_Embedding.UpdateInputWeights(NeuronOCL)) return false; if(!KV_Embedding.UpdateInputWeights(GetPointer(Transpose))) return false; if(!W0.UpdateInputWeights(GetPointer(MHAttentionOut))) return false; if(!FF[0].UpdateInputWeights(GetPointer(AttentionOut))) return false; if(!FF[1].UpdateInputWeights(GetPointer(FF[0]))) return false; //--- return true; }
我们研究规划交叉关系过程的方法到此结束。您可在附件中找到该类及其所有方法的完整代码。我们正在转入构建训练和测试模型的智能系统。
2.2模型架构
从 ADAPT 方法的理论描述中可以看出,所提议方法具有相当复杂的层次结构。对我们来说,这意味着需要大量已训练模型。我们将它们的架构描述分为 2 种方法。首先,我们将创建 3 个与端点预测过程相关的模型。
bool CreateTrajNetDescriptions(CArrayObj *encoder, CArrayObj *endpoints, CArrayObj *probability) { //--- CLayerDescription *descr; //--- if(!encoder) { encoder = new CArrayObj(); if(!encoder) return false; } if(!endpoints) { endpoints = new CArrayObj(); if(!endpoints) return false; } if(!probability) { probability = new CArrayObj(); if(!probability) return false; }
环境状态编码器接收描述 1 个状态的原始输入数据。
//--- 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[] = {prev_count}; ArrayCopy(descr.windows, temp); } prev_count = descr.count = GPTBars; int prev_wout = descr.window_out = EmbeddingSize; if(!encoder.Add(descr)) { delete descr; return false; }
然后我们涉猎位置编码。
//--- layer 3 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronPEOCL; descr.count = prev_count; descr.window = prev_wout; if(!encoder.Add(descr)) { delete descr; return false; }
接下来是综合关注模块。为了方便管理模型架构,我们将基于模块迭代的次数创建一个循环。
for(int l = 0; l < Lenc; l++) { //--- layer 4 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronTransposeOCL; descr.count = prev_count; descr.window = prev_wout; if(!encoder.Add(descr)) { delete descr; return false; }
根据 ADAPT 方法作者提议的算法,我们首先检查折线(在我们的例子中是状态)和个体之间的关系。按该方向使用我们的交叉关系模块之前,我们需要转置得到的信息量。然后我们添加新层。
//--- layer 5 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronMH2AttentionOCL; descr.count = prev_wout; descr.window = prev_count; descr.step = 8; descr.window_out = 16; descr.optimization = ADAM; if(!encoder.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 = 8; descr.window_out = 16; descr.layers = 1; descr.optimization = ADAM; if(!encoder.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(!encoder.Add(descr)) { delete descr; return false; }
//--- layer 8 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronMH2AttentionOCL; descr.count = prev_count; descr.window = prev_wout; descr.step = 8; descr.window_out = 16; descr.layers = 1; descr.optimization = ADAM; if(!encoder.Add(descr)) { delete descr; return false; }
//--- layer 9 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronMLMHAttentionOCL; descr.count = prev_count; descr.window = prev_wout; descr.step = 8; descr.window_out = 16; descr.layers = 1; descr.optimization = ADAM; if(!encoder.Add(descr)) { delete descr; return false; } }
如上所述,我们将编码器模块包装到一个循环当中。循环迭代次数以常量形式提供。
#define Lenc 3 //Number ADAPT Encoder blocks
因此,更改一个常量可以让我们快速更改编码器中关注模块的数量。
编码器结果用于预测多组端点。该集合的数量由 NForecast 常量确定。
#define NForecast 5 //Number of forecast
我们将用一个简单的 MLP 作为端点预测模型。在该模型中,从编码器接收的数据穿透全连接层。
//--- Endpoints endpoints.Clear(); //--- Input layer if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; prev_count = descr.count = (prev_count * prev_wout); descr.activation = None; descr.optimization = ADAM; if(!endpoints.Add(descr)) { delete descr; return false; }
//--- layer 1 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = LatentCount; descr.activation = SIGMOID; descr.optimization = ADAM; if(!endpoints.Add(descr)) { delete descr; return false; }
潜状态经由 SoftMax 函数常规化。
//--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronSoftMaxOCL; descr.count = LatentCount; descr.step = 1; descr.activation = None; descr.optimization = ADAM; if(!endpoints.Add(descr)) { delete descr; return false; }
接下来,我们在全连接层中生成端点。
//--- layer 3 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = 3 * NForecast; descr.activation = None; descr.optimization = ADAM; if(!endpoints.Add(descr)) { delete descr; return false; }
用于预测选择轨迹概率的模型也采用编码器的结果作为输入数据。
//--- Probability probability.Clear(); //--- Input layer if(!probability.Add(endpoints.At(0))) return false;
但在其中,它们在分析时参考已预测端点。
//--- layer 1 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConcatenate; descr.count = LatentCount; descr.window = prev_count; descr.step = 3 * NForecast; descr.optimization = ADAM; descr.activation = SIGMOID; if(!probability.Add(descr)) { delete descr; return false; }
//--- layer 2 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = LatentCount; descr.activation = LReLU; descr.optimization = ADAM; if(!probability.Add(descr)) { delete descr; return false; }
具有概率量的操作允许我们在模型的输出中调用 SoftMax 层。
//--- layer 3 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = NForecast; descr.activation = None; descr.optimization = ADAM; if(!probability.Add(descr)) { delete descr; return false; }
//--- layer 4 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronSoftMaxOCL; descr.count = NForecast; descr.step = 1; descr.activation = None; descr.optimization = ADAM; if(!probability.Add(descr)) { delete descr; return false; } //--- return true; }
现在我们来到了关键点,于此我们要针对 ADAPT 方法算法进行根本性修改。我们的修改是由金融市场的特殊情况所要求的。然而,以我的观点,它们绝对不与该方法作者提议的方法相矛盾。
作者所提议的自己算法来解决与自动驾驶车辆导航相关的问题。在此,轨迹预测的品质极其重要。因为 2 辆或更多车辆在轨迹的任何部分发生碰撞,都可能导致严重后果。
在金融市场交易的情况下,更关注控制点。我们对价格走势的轨迹,及其在总体趋势范围内的小幅波动并不那么感兴趣。对我们来说更重要的是,在这波走势的框架内,最大可能的盈利和回撤的极端情况。
因此,我们排除了轨迹预测模块,用扮演者模型代替,该模型将生成交易的参数。同时,我们保留了训练模型的常用方式。我们稍后再回来。
我们的扮演者使用 4 个数据源来制定决策:
- 状态嵌入
- 账户状态描述
- 预测的端点集合
- 每组预测端点的概率
以前,我们创建了一个仅组合 2 个信息流的机制。为了组合 4 个流,我们将构建一个级联模型。
bool CreateDescriptions(CArrayObj *actor, CArrayObj *end_encoder, CArrayObj *state_encoder) { //--- CLayerDescription *descr; //--- if(!actor) { actor = new CArrayObj(); if(!actor) return false; } if(!end_encoder) { end_encoder = new CArrayObj(); if(!end_encoder) return false; } if(!state_encoder) { state_encoder = new CArrayObj(); if(!state_encoder) return false; }
我们将预测的端点集合,及其概率合并到端点嵌入之中。
//--- Endpoints Encoder end_encoder.Clear(); //--- Input layer if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; int prev_count = descr.count = 3 * NForecast; descr.activation = None; descr.optimization = ADAM; if(!end_encoder.Add(descr)) { delete descr; return false; } //--- layer 1 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConcatenate; descr.count = LatentCount; descr.window = prev_count; descr.step = NForecast; descr.optimization = ADAM; descr.activation = LReLU; if(!end_encoder.Add(descr)) { delete descr; return false; }
我们将环境状态嵌入与余额和持仓参数相结合。
//--- State Encoder state_encoder.Clear(); //--- Input layer if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; prev_count = descr.count = GPTBars * EmbeddingSize; descr.activation = None; descr.optimization = ADAM; if(!state_encoder.Add(descr)) { delete descr; return false; } //--- layer 1 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConcatenate; descr.count = LatentCount; descr.window = prev_count; descr.step = AccountDescr; descr.optimization = ADAM; descr.activation = SIGMOID; if(!state_encoder.Add(descr)) { delete descr; return false; }
我们将 2 个指定模型的工作成果传递给扮演者进行决策。
//--- Actor actor.Clear(); //--- Input layer if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; prev_count = descr.count = LatentCount; descr.activation = None; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; } //--- layer 1 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronConcatenate; descr.count = LatentCount; descr.window = prev_count; descr.step = LatentCount; descr.optimization = ADAM; descr.activation = LReLU; if(!actor.Add(descr)) { delete descr; return false; }
在扮演者内部,我们使用完全连接的层。
//--- layer 2 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 3 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 4 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronVAEOCL; descr.count = NActions; descr.optimization = ADAM; if(!actor.Add(descr)) { delete descr; return false; } //--- return true; }
如您所见,我们计划使用尽可能简单的模型架构。这是 ADAPT 方法的优势之一。
在本文中,我决定不详述与环境交互的智能系统的细节。收集的数据结构,以及与环境交互的方法均无改变。当然,针对制定决策的调用模型顺序进行了更改。我建议您研究代码,查看其顺序。完整的 EA 代码可在附件中找到。但是模型训练 EA 有几个独特的方面。
2.3模型训练
与前几篇文章不同,这次我们将在一个 EA “...\Experts\ADAPT\Study.mq5” 中训练所有模型。这是因为我们需要将误差梯度从几乎所有模型转移到环境编码器之中。
EA 初始化方法是根据标准制程构建的。首先,我们加载训练数据集。
int OnInit() { //--- ResetLastError(); if(!LoadTotalBase()) { PrintFormat("Error of load study data: %d", GetLastError()); return INIT_FAILED; }
然后,分 2 个阶段,我们加载之前创建的模型,并在必要时创建新的模型。
//--- load models float temp; if(!ADAPTEncoder.Load(FileName + "Enc.nnw", temp, temp, temp, dtStudied, true) || !ADAPTEndpoints.Load(FileName + "Endp.nnw", temp, temp, temp, dtStudied, true) || !ADAPTProbability.Load(FileName + "Prob.nnw", temp, temp, temp, dtStudied, true) ) { CArrayObj *encoder = new CArrayObj(); CArrayObj *endpoint = new CArrayObj(); CArrayObj *prob = new CArrayObj(); if(!CreateTrajNetDescriptions(encoder, endpoint, prob)) { delete endpoint; delete prob; delete encoder; return INIT_FAILED; } if(!ADAPTEncoder.Create(encoder) || !ADAPTEndpoints.Create(endpoint) || !ADAPTProbability.Create(prob)) { delete endpoint; delete prob; delete encoder; return INIT_FAILED; } delete endpoint; delete prob; delete encoder; }
if(!StateEncoder.Load(FileName + "StEnc.nnw", temp, temp, temp, dtStudied, true) || !EndpointEncoder.Load(FileName + "EndEnc.nnw", temp, temp, temp, dtStudied, true) || !Actor.Load(FileName + "Act.nnw", temp, temp, temp, dtStudied, true)) { CArrayObj *actor = new CArrayObj(); CArrayObj *endpoint = new CArrayObj(); CArrayObj *encoder = new CArrayObj(); if(!CreateDescriptions(actor, endpoint, encoder)) { delete actor; delete endpoint; delete encoder; return INIT_FAILED; } if(!Actor.Create(actor) || !StateEncoder.Create(encoder) || !EndpointEncoder.Create(endpoint)) { delete actor; delete endpoint; delete encoder; return INIT_FAILED; } delete actor; delete endpoint; delete encoder; //--- }
我们将所有模型传输到单个 OpenCL关联环境之中。
OpenCL = Actor.GetOpenCL(); StateEncoder.SetOpenCL(OpenCL); EndpointEncoder.SetOpenCL(OpenCL); ADAPTEncoder.SetOpenCL(OpenCL); ADAPTEndpoints.SetOpenCL(OpenCL); ADAPTProbability.SetOpenCL(OpenCL);
控制模型架构。
Actor.getResults(Result); if(Result.Total() != NActions) { PrintFormat("The scope of the actor does not match the actions count (%d <> %d)", NActions, Result.Total()); return INIT_FAILED; } //--- ADAPTEndpoints.getResults(Result); if(Result.Total() != 3 * NForecast) { PrintFormat("The scope of the Endpoints does not match forecast endpoints (%d <> %d)", 3 * NForecast, Result.Total()); return INIT_FAILED; } //--- ADAPTEncoder.GetLayerOutput(0, Result); if(Result.Total() != (HistoryBars * BarDescr)) { PrintFormat("Input size of Encoder doesn't match state description (%d <> %d)", Result.Total(), (HistoryBars * BarDescr)); return INIT_FAILED; }
创建辅助缓冲区。
if(!bGradient.BufferInit(MathMax(AccountDescr, NForecast), 0) || !bGradient.BufferCreate(OpenCL)) { PrintFormat("Error of create buffers: %d", GetLastError()); return INIT_FAILED; }
为模型训练的开始生成自定义事件。
if(!EventChartCustom(ChartID(), 1, 0, 0, "Init")) { PrintFormat("Error of create study event: %d", GetLastError()); return INIT_FAILED; } //--- return(INIT_SUCCEEDED); }
训练过程本身是由 Train 方法组织的。
void Train(void) { //--- vector<float> probability = GetProbTrajectories(Buffer, 0.9);
在方法的主体中,我们首先创建一个概率向量,从经验回放缓冲区中选择轨迹。然后我们创建所需的局部变量。
vector<float> result, target; matrix<float> targets, temp_m; bool Stop = false; //--- uint ticks = GetTickCount();
如常,训练是在嵌套循环系统中实现的。在外循环的主体中,我们对轨迹和其上的学习状态数据包进行采样。
for(int iter = 0; (iter < Iterations && !IsStopped() && !Stop); iter ++) { 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; } ADAPTEncoder.Clear(); int end = MathMin(state + batch, Buffer[tr].Total - PrecoderBars);
依据一系列历史数据训练模型的过程是在嵌套循环中构建的。
for(int i = state; i < end; i++) { bState.AssignArray(Buffer[tr].States[i].state);
我们取一个环境状态,并将其传递给编码器。
//--- Trajectory if(!ADAPTEncoder.feedForward((CBufferFloat*)GetPointer(bState), 1, false, (CBufferFloat*)NULL)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
然后,我们生成预测端点集合,及其概率。
if(!ADAPTEndpoints.feedForward((CNet*)GetPointer(ADAPTEncoder), -1, (CBufferFloat*)NULL)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
if(!ADAPTProbability.feedForward((CNet*)GetPointer(ADAPTEncoder), -1, (CNet*)GetPointer(ADAPTEndpoints))) { 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(target.Size() > BarDescr) { matrix<float> temp(1, target.Size()); temp.Row(target, 0); temp.Reshape(target.Size() / BarDescr, BarDescr); temp.Resize(temp.Rows(), 3); target = temp.Row(temp.Rows() - 1); } targets.Row(target, t); }
但是我们没有使用其中的最后一个状态,也许就如从端点的定义中所思。取而代之,我们寻找最近的极值。首先,我们计算每根蜡烛的收盘价与所分析状态的偏差累积总和。对于获得的值,我们在每根柱线的最高价和最低价上加入间隔。我们将计算结果保存在矩阵之中。
target = targets.Col(0).CumSum(); targets.Col(target, 0); targets.Col(target + targets.Col(1), 1); targets.Col(target + targets.Col(2), 2);
在结果矩阵中,我们找到最近的极值。
int extr = 1; if(target[0] == 0) target[0] = target[1]; int direct = (target[0] > 0 ? 1 : -1); for(int i = 1; i < PrecoderBars; i++) { if((target[i]*direct) < 0) break; extr++; }
以所找到的最近极值形成一个向量。
targets.Resize(extr, 3); if(direct >= 0) { target = targets.Max(AXIS_HORZ); target[2] = targets.Col(2).Min(); } else { target = targets.Min(AXIS_HORZ); target[1] = targets.Col(1).Max(); }
在预测端点集合中,我们判定偏差最小的向量,并将其替换为目标值。
ADAPTEndpoints.getResults(result); targets.Reshape(1, result.Size()); targets.Row(result, 0); targets.Reshape(NForecast, 3); temp_m = targets; for(int i = 0; i < 3; i++) temp_m.Col(temp_m.Col(i) - target[i], i); temp_m = MathPow(temp_m, 2.0f); ulong pos = temp_m.Sum(AXIS_VERT).ArgMin(); targets.Row(target, pos);
我们使用结果矩阵来训练预测目标点的模型。
Result.AssignArray(targets); //--- if(!ADAPTEndpoints.backProp(Result, (CBufferFloat*)NULL)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
我们将误差梯度传播到编码器模型,并更新其参数。
if(!ADAPTEncoder.backPropGradient((CBufferFloat*)NULL)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
在此,我们还要训练一个预测轨迹概率的模型。但它的误差梯度不会传播到其它模型。
bProbs.AssignArray(vector<float>::Zeros(NForecast)); bProbs.Update((int)pos, 1); bProbs.BufferWrite(); if(!ADAPTProbability.backProp(GetPointer(bProbs), GetPointer(ADAPTEndpoints))) { 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();
接下来,我们按顺序调用扮演者模型级联的前馈方法。
//--- State embedding if(!StateEncoder.feedForward((CNet *)GetPointer(ADAPTEncoder), -1, (CBufferFloat*)GetPointer(bAccount))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
此处应当注意的是,我们没有使用端点集合的预测值及其概率,而是使用目标值的张量,我们在上面曾用这些张量来训练相应的模型。
//--- Endpoint embedding if(!EndpointEncoder.feedForward(Result, -1, false, (CBufferFloat*)GetPointer(bProbs))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
//--- Actor if(!Actor.feedForward((CNet *)GetPointer(StateEncoder), -1, (CNet*)GetPointer(EndpointEncoder))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
在前馈验算之后,我们需要更新模型参数。为此,我们需要目标值。根据 ADAPT 方法,应训练一个模型来预测来自经验回放缓冲区的真实数据的轨迹。我们可以像以前一样,从经验回放缓冲区中取个体动作。但在这种情况下,我们没有评估此类行动,及判定其优先级的机制。
在这种状况下,我决定采取不同的方式。既然我们已经有了基于训练数据集中后续价格走势的真实数据的目标端点值,为什么我们不用它们来在分析条件下生成“最优”交易呢。我们判定 “最优” 交易的方向和交易价位。我们采取持仓量,要参考每笔交易净值 1% 风险的情况。
result = vector<float>::Zeros(NActions); double value = SymbolInfoDouble(_Symbol, SYMBOL_TRADE_TICK_VALUE_LOSS); double risk = AccountInfoDouble(ACCOUNT_EQUITY) * 0.01; if(direct > 0) { float tp = float(target[1] / _Point / MaxTP); result[1] = tp; int sl = int(MathMax(MathMax(target[1] / 3, -target[2]) / _Point, MaxSL/10)); result[2] = float(sl) / MaxSL; result[0] = float(MathMax(risk / (value * sl), 0.01))+FLT_EPSILON; } else { float tp = float((-target[2]) / _Point / MaxTP); result[4] = tp; int sl = int(MathMax(MathMax((-target[2]) / 3, target[1]) / _Point, MaxSL/10)); result[5] = float(sl) / MaxSL; result[3] = float(MathMax(risk / (value * sl), 0.01))+FLT_EPSILON; }
在计算持仓量时,我们使用净值,因为在交易时账户可能已经有持仓,其盈利(亏损)尚未计入账户余额。
以这种方式生成的 “最优” 仓位用于训练扮演者模型。
Result.AssignArray(result); if(!Actor.backProp(Result, (CNet *)GetPointer(EndpointEncoder)) || !StateEncoder.backPropGradient(GetPointer(bAccount), (CBufferFloat *)GetPointer(bGradient)) || !EndpointEncoder.backPropGradient(GetPointer(bProbs), (CBufferFloat *)GetPointer(bGradient)) ) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
我们用扮演者模型训练中的误差梯度来更新编码器参数。
if(!ADAPTEncoder.backPropGradient((CBufferFloat*)NULL)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
请注意,我们在此阶段不会更新端点预测模型参数。此限制由 ADAPT 方法的作者引入,旨在提高模型训练的稳定性。
在更新了所有模型的参数后,我们需要做的就是通知用户训练过程的进度,并转入循环系统的下一次迭代。
//--- 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", "Endpoints", percent, ADAPTEndpoints.getRecentAverageError()); str += StringFormat("%-14s %6.2f%% -> Error %15.8f\n", "Probability", percent, ADAPTProbability.getRecentAverageError()); Comment(str); ticks = GetTickCount(); } } }
在方法结束时,我们清除图表上的注释字段。将模型训练结果记录到日志当中。然后启动 EA 终止。
Comment(""); //--- PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Actor", Actor.getRecentAverageError()); PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Endpoints", ADAPTEndpoints.getRecentAverageError()); PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Probability", ADAPTProbability.getRecentAverageError()); ExpertRemove(); //--- }
我们利用 MQL5 实现算法描述的愿景到此结束。您可以在附件中找到文章中用到的全部程序的完整代码。
3. 测试
我们已经做了大量工作来利用 MQL5 实现 ADAPT 方法。我们的实现与原始算法相去甚远。尽管如此,它本着所提议方法的精神,并探索了在所分析场景的对象之间,综合分析相关关系的原始思路。现在是时候在策略测试器中,依据真实历史数据测试我们的工作成果了。
这些模型取用 2023 年前 7 个月的 EURUSD H1 历史数据进行训练。所有指标都采用默认参数。
训练后的模型完全按照训练参数进行了测试。我们只更改了历史数据的时间间隔。在这个阶段,我们取用 2023 年 8 月的历史数据。
由于在与环境交互的过程中所收集数据的结构没有改变,因此我在实验中并未收集新的训练数据。为了训练模型,我使用了以前训练模型时收集的验算结果。甚至,所提议计算 “最优交易” 的方式令我们能够避免计算额外的验算,从而优化和补充训练数据空间。
在此,看似一遍验算就足以训练模型。不过,在训练过程中,我们需要为模型提供尽可能多的多样化信息,包括有关账户状态和持仓的信息。
基于测试结果,我们可以对所研究方法的有效性得出结论。模型的简单性允许更快地训练模型。模型的训练结果证实了所提议方法的有效性,该模型展现出在训练和测试数据集合上都产生利润的能力。
结束语
本文讨论的 ADAPT 方法是预测各种复杂场景中个体轨迹的创新方法。该方式效率高,仅需少量的计算资源,并为场景中的每个个体提供高品质的预测。
针对 ADAPT 方法的改进包括一个自适应头脑,它可以在不增加模型大小的情况下增加模型的能力,以及采用权重的动态学习来更好地适应每一个体的各自状况。这些创新极大地促进了有效的轨迹预测。
在本文的实践部分,我们利用 MQL5 实现了我们所提议方法的愿景。我们采用真实的历史数据训练和测试模型。基于获得的结果,我们可以得出关于 ADAPT 方法的有效性,以及利用其变体构建模型,并在金融市场中运作的可行性结论。
不过,我想提醒您,本文中阐述的任何程序都仅用于演示该技术,且并不准备在现实世界的金融交易中运用。
参考
文中所用程序
# | 已发行 | 类型 | 说明 |
---|---|---|---|
1 | Research.mq5 | 智能交易系统 | 样本收集 EA |
2 | ResearchRealORL.mq5 | 智能交易系统 | 用于使用 Real-ORL 方法收集示例的 EA |
3 | Study.mq5 | 智能交易系统 | 模型训练 EA |
4 | Test.mq5 | 智能交易系统 | 模型测试 EA |
5 | Trajectory.mqh | 类库 | 系统状态定义结构 |
6 | NeuroNet.mqh | 类库 | 创建神经网络的类库 |
7 | NeuroNet.cl | 代码库 | OpenCL 程序代码库 |
本文由MetaQuotes Ltd译自俄文
原文地址: https://www.mql5.com/ru/articles/14143

