
神经网络变得简单(第 78 部分):带有变换器的无解码对象检测器(DFFT)
概述
在之前的文章中,我们主要专注预测即将到来的价格走势和分析历史数据。基于此分析,我们尝试以各种方式预测即将到来的最有可能的价格走势。一些策略会构造整个预测走势范围,并试图估算每个预测的概率。自然,训练和操作此类模型需要大量的计算资源。
但我们真的需要预测即将到来的价格走势吗?甚至,所得预测的准确性远非理想。
我们的终极目标是产生盈利,我们希望从个体的成功交易中赚取盈利。反过来,个体可基于所获预测价格轨迹选择最优动作。
由此,构造预测轨迹时的误差会潜在导致个体在选择动作时出现更大的错误。我说“潜在导致”是因为在学习过程中,扮演者可以适应预测误差,并稍微抵消误差。不过,如果预测误差相对恒定,则可能会出现这种状况。在随机预测误差的情况下,个体动作中的误差只会增加。
在这种状况下,我们会寻找误差最小化的方式。如果我们剔除预测即将到来的价格走势轨迹的过渡阶段呢?我们回到经典的强化学习方式。我们将让扮演者基于历史数据分析来选择动作。然而,这并不意味着后退一步,而更像是往侧面迈出一步。
我建议您领略一种有趣的方法,其用于解决计算机视觉领域的问题。这就是无解码器完全基于变换器(DFFT) 的方法,其在《使用变换器进行高效无解码器对象检测》一文中讲述过。
本文提议的 DFFT 方法确保了训练阶段和操作阶段的高效率。该方法的作者把对象检测简化为仅用一个编码器的单级密集预测任务。它们专注于解决 2 个问题:
- 剔除低效的解码器,使用 2 个强大的编码器来保持单级特征映像预测精度;
- 学习低级语义特征是为计算受限的检测任务。
特别是,该方法的作者提议一种新的轻量级面向检测的变换器主干,它可以有效地捕获蕴含丰富语义的低级特征。论文中讲述的实验明示,计算成本降低,训练局数更少。
1. DFFT 算法
无解码器完全基于变换器(DFFT)方法是一种完全基于无解码器变换器的高效对象检测器。变换器主干专注于对象检测。它在四个尺度提取它们,并将它们发送到下一个单级编码器,仅有密度预测模块。预测模块首先使用 尺度聚合编码器 将多尺度特征聚合到单一特征映射。
然后,该方法的作者建议使用 任务对齐编码器针对分类和回归问题进行同步特征匹配。
面向检测的变换器(DOT)主干旨在提取含有严格语义的多尺度特征。它在层次结构上堆叠了一个嵌入模块和 4 个 DOT 阶段。新的语义增强关注度模块聚合了 DOT 每两个连续阶段的低级语义信息。
在处理高分辨率特征映射,以便进行密集预测时,传统的变换器模块把多目关注度(MSA)替换为局部空间关注度层,和窗口偏置多目自注意力(SW-MSA),从而降低计算成本。不过,这种结构降低了检测性能,因为它仅提取包含有限低级语义的多尺度对象。
为了减轻这一缺点,DFFT 方法的作者在 DOT 模块中添加了若干个 SW-MSA 模块和一个跨通道的全局关注度模块。注意,每个关注度模块都包含一个关注度层和一个 FFN 层。
该方法的作者发现,在连续的局部空间关注度层之后的通道上放置一个轻量关注度层可以帮助推断每个尺度上对象的语义。
尽管 DOT 模块通过跨通道的全局关注来提升低级特征中的语义信息,但语义可以进一步改善检测任务。为此目的,该方法的作者提议一个新的语义增强关注度模块(SAA),其在两个连续的 DOT 层之间交换语义信息,并补充它们的特征。SAA 由一个上行采样层,和一个跨通道的全局关注度模块组成。该方法的作者在每两个连续的 DOT 模块中加入 SAA。形式上,SAA 接受当前 DOT 模块和前一阶段 DOT 的结果,然后返回一个语义增强函数,其被发送到下一个 DOT 阶段,也为最终的多尺度特征做贡献。
一般来说,面向检测的阶段由四个 DOT 层组成,其中每个阶段包括一个 DOT 模块,和一个 SAA 模块(第一阶段除外)。特别是,第一阶段包含一个 DOT 模块,且不包含一个 SAA 模块,因为 SAA 模块输入来自两个连续的 DOT 阶段。接下来是一个下行采样层,重构输入维度。
以下模块旨在提升推理效率和模型训练效率 DFFT。首先,它使用 尺度聚合编码器(SAE) 将来自 DOT 主干的多尺度对象聚合到一个 Ssae 对象映射之中。
然后,它使用任务对齐编码器(TAE)在一个头中同时创建对齐分类函数 𝒕cls 和回归函数 𝒕reg。
聚合尺度编码器由 3 个 SAE 模块构建而成。每个 SAE 模块取两个对象作为输入数据,并在所有 SAE 模块中逐步聚合它们。该方法的作者使用对象的有限聚合尺度来平衡检测精度和计算成本。
典型情况下,检测器使用两个独立的分支(未连接的头)彼此独立地执行对象分类和定位。这种双分支结构未考虑到两个任务之间的交互,导致预测不一致。同时,在针对两个任务学习特征时,通常会在共轭头中产生冲突。DFFT 方法的作者提议使用任务专属编码器,该编码器通过将跨通道的关注度单元组合在一个连接的头部组中,在学习交互式和任务专属功能之间提供更好的平衡。
该编码器由两种通道关注度模块组成。首先,跨通道的多级组关注度模块对齐,并将 Ssae 聚合对象分成 2 个部分。其次,跨通道的全局关注度模块为两个分离的对象之一进行编码,以便用于后续的回归任务。
特别是,通道关注度的组模块和通道关注度的全局模块之间的区别在于,除了跨通道的组关注度模块中的 Query/Key/Value 嵌入的投影外,所有线性投影都是在两组中执行的。因此,在关注度操作中特征交互,同时在输出投影中单独输出。
由论文作者所表述方法的原始可视化提供如下。
2. 利用 MQL5 实现
在研究了无解码器完全基于变换器(DFFT)方法的理论方面之后,我们转到利用 MQL5 实现所提议的方式。不过,我们的模型会与原始方法略有不同。在构建模型时,我们会参考所提议方法的计算机视觉问题的具体细节的差异,以及我们构建模型所针对的金融市场操作的差异。
2.1DOT 模块构造
在我们开始之前,请注意,提议的方式与我们早前构建的模型有很大不同。DOT 模块也不同于我们早前研究的关注度模块。因此,我们的工作是从构建一个新的 CNeuronDOTOCL 神经层开始。我们创建新层作为 CNeuronBaseOCL(神经层的基类)的后代。
与其它关注度模块类似,我们将添加变量来存储关键参数:
- iWindowSize — 单个序列元素的窗口大小;
- iPrevWindowSize — 前一层序列的单个元素的窗口大小;
- iDimension — 内部实体 Query、Key 和 Value 的向量大小;
- iUnits — 序列中的元素数量;
- iHeads — 关注度头的数量。
我想您注意到了变量 iPrevWindowSize。添加该变量将允许我们实现逐层压缩数据的能力,如同 DFFT 方法所提供的。
此外,为了将在新类中的直接工作最小化,并最大化利用先前创建的开发,我们使用函数库中嵌套的神经层来实现部分功能。在实现前馈和反向验算方法时,我们将仔细研究它们的功能。
class CNeuronDOTOCL : public CNeuronBaseOCL { protected: uint iWindowSize; uint iPrevWindowSize; uint iDimension; uint iUnits; uint iHeads; //--- CNeuronConvOCL cProjInput; CNeuronConvOCL cQKV; int iScoreBuffer; CNeuronBaseOCL cRelativePositionsBias; CNeuronBaseOCL MHAttentionOut; CNeuronConvOCL cProj; CNeuronBaseOCL AttentionOut; CNeuronConvOCL cFF1; CNeuronConvOCL cFF2; CNeuronBaseOCL SAttenOut; CNeuronXCiTOCL cCAtten; //--- virtual bool feedForward(CNeuronBaseOCL *NeuronOCL); virtual bool DOT(void); //--- virtual bool updateInputWeights(CNeuronBaseOCL *NeuronOCL); virtual bool updateRelativePositionsBias(void); virtual bool DOTInsideGradients(void); public: CNeuronDOTOCL(void) {}; ~CNeuronDOTOCL(void) {}; //--- virtual bool Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint dimension, uint heads, uint units_count, uint prev_window, ENUM_OPTIMIZATION optimization_type, uint batch); virtual bool calcInputGradients(CNeuronBaseOCL *prevLayer); //--- virtual int Type(void) const { return defNeuronDOTOCL; } //--- 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); };
通常,重写方法的清单都是标准的。
在类主体中,我们使用静态对象。这允许我们将类的构造函数和析构函数留空。
该类在 Init 方法中初始化。将必要的数据传递给方法的参数。对信息的最小必要控制是在父类的相关方法中实现的。此处我们还一并初始化继承的对象。
bool CNeuronDOTOCL::Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint window, uint dimension, uint heads, uint units_count, uint prev_window, ENUM_OPTIMIZATION optimization_type, uint batch) { //--- if(!CNeuronBaseOCL::Init(numOutputs, myIndex, open_cl, window * units_count, optimization_type, batch)) return false;
然后我们检查源数据的大小是否与当前层的参数匹配。如有必要,初始化数据伸缩层。
if(prev_window != window) { if(!cProjInput.Init(0, 0, OpenCL, prev_window, prev_window, window, units_count, optimization_type, batch)) return false; }
接下来,我们把从调用者那里收到的基本常量保存下来,这些常量将层的架构定义为内部类变量。
iWindowSize = window; iPrevWindowSize = prev_window; iDimension = dimension; iHeads = heads; iUnits = units_count;
然后我们按顺序初始化所有内部对象。首先,我们初始化生成 Query、Key 和 Value 层。我们将在一个神经层 cQKV 的主体中并行生成所有 3 个实体。
if(!cQKV.Init(0, 1, OpenCL, window, window, dimension * heads, units_count, optimization_type, batch)) return false;
接下来,我们将创建 iScoreBuffer 缓冲区来记录对象依赖系数。此处需要注意的是,在 DOT 模块中,我们首先分析局部语义。为此,我们检查对象与其 2 个最近邻居之间的依赖关系。因此,我们将 Score 缓冲区大小定义为 iUnits * iHeads * 3。
此外,存储在缓冲区中的系数会在每次前馈验算时重新计算。它们仅在下一个反向传播验算时使用。因此,我们不会将缓冲区数据保存到模型保存文件之中。甚至,我们不会在主程序的内存中创建缓冲区。我们只需要在 OpenCL 关联环境内存中创建一个缓冲区。在主程序端,我们将仅存储指向缓冲区的指针。
//--- iScoreBuffer = OpenCL.AddBuffer(sizeof(float) * iUnits * iHeads * 3, CL_MEM_READ_WRITE); if(iScoreBuffer < 0) return false;
在窗口式自关注机制中,与传统的变换器不同,每个令牌仅与特定窗口内的令牌交互。这大大降低了计算复杂性。不过,该限制也意味着模型必须要考虑令牌在窗口内的相对位置。为了实现该功能,我们引入了可训练参数 cRelativePositionsBias。对于 iWindowSize 窗口内的每一对令牌 (i, j),cRelativePositionsBias 包含一个权重,即基于这些它们的相对位置判定这些令牌之间交互的重要性。
该缓冲区的大小等于 Score 系数缓冲区的大小。然而,为了训练参数,除了数值本身的缓冲区之外,我们还需要额外的缓冲区。为了减少内部对象的数量,以及代码的可读性,对于 cRelativePositionsBias,我们将声明一个包含所有额外缓冲区的神经层对象。
if(!cRelativePositionsBias.Init(1, 2, OpenCL, iUnits * iHeads * 3, optimization_type, batch)) return false;
类似地,我们添加自关注机制的其余元素。
if(!MHAttentionOut.Init(0, 3, OpenCL, iUnits * iHeads * iDimension, optimization_type, batch)) return false; if(!cProj.Init(0, 4, OpenCL, iHeads * iDimension, iHeads * iDimension, window, iUnits, optimization_type, batch)) return false; if(!AttentionOut.Init(0, 5, OpenCL, iUnits * window, optimization_type, batch)) return false; if(!cFF1.Init(0, 6, OpenCL, window, window, 4 * window, units_count, optimization_type,batch)) return false; if(!cFF2.Init(0, 7, OpenCL, window * 4, window * 4, window, units_count, optimization_type, batch)) return false; if(!SAttenOut.Init(0, 8, OpenCL, iUnits * window, optimization_type, batch)) return false;
作为全局关注度模块,我们用到 CNeuronXCiTOCL 层。
if(!cCAtten.Init(0, 9, OpenCL, window, MathMax(window / 2, 3), 8, iUnits, 1, optimization_type, batch)) return false;
为了最大限度地减少缓冲区之间的数据复制操作,我们将替换对象和缓冲区。
if(!!Output) delete Output; Output = cCAtten.getOutput(); if(!!Gradient) delete Gradient; Gradient = cCAtten.getGradient(); SAttenOut.SetGradientIndex(cFF2.getGradientIndex()); //--- return true; }
完成方法执行。
类经初始化之后,我们转到构建前馈算法。现在我们转到在 OpenCL 程序端组织窗口式自关注机制。为此,我们创建了 DOTFeedForward 内核。在内核的参数中,我们将传递指向 4 个数据缓冲区的指针:
- qkv — Query、Key 和 Value 实体缓冲区,
- score — 依赖系数缓冲区,
- rpb — 位置偏移缓冲区,
- out — 多头窗口自关注的结果缓冲区。
__kernel void DOTFeedForward(__global float *qkv, __global float *score, __global float *rpb, __global float *out) { const size_t d = get_local_id(0); const size_t dimension = get_local_size(0); const size_t u = get_global_id(1); const size_t units = get_global_size(1); const size_t h = get_global_id(2); const size_t heads = get_global_size(2);
我们计划在 3-维任务空间中启动内核。在内核的主体中,我们在所有 3 个维度中标识线程。此处应注意的是,在 Query、Key 和 Value 实体维度的第一个维度中,我们在本地内存中创建一个共享的缓冲区工作组。
接下来,我们在分析对象之前判定其在数据缓冲区中的偏移量。
uint step = 3 * dimension * heads; uint start = max((int)u - 1, 0); uint stop = min((int)u + 1, (int)units - 1); uint shift_q = u * step + h * dimension; uint shift_k = start * step + dimension * (heads + h); uint shift_score = u * 3 * heads;
我们还在此处创建了一个局部缓冲区,用于同一工作组的线程之间的数据交换。
const uint ls_d = min((uint)dimension, (uint)LOCAL_ARRAY_SIZE); __local float temp[LOCAL_ARRAY_SIZE][3];
如早前所述,我们据对象的 2 个最近邻来判定局部语义。首先,我们判定近邻对所分析对象的影响。我们计算工作组内的依赖系数。首先,我们将 Query 和 Key 两个实体中的元素成对相乘,在并行流中。
//--- Score if(d < ls_d) { for(uint pos = start; pos <= stop; pos++) { temp[d][pos - start] = 0; } for(uint dim = d; dim < dimension; dim += ls_d) { float q = qkv[shift_q + dim]; for(uint pos = start; pos <= stop; pos++) { uint i = pos - start; temp[d][i] = temp[d][i] + q * qkv[shift_k + i * step + dim]; } } barrier(CLK_LOCAL_MEM_FENCE);
然后我们汇总得到的乘积。
int count = ls_d; do { count = (count + 1) / 2; if(d < count && (d + count) < dimension) for(uint i = 0; i <= (stop - start); i++) { temp[d][i] += temp[d + count][i]; temp[d + count][i] = 0; } barrier(CLK_LOCAL_MEM_FENCE); } while(count > 1); }
我们将偏移参数添加到获得的值中,并使用 SoftMax 函数进行常规化。
if(d == 0) { float sum = 0; for(uint i = 0; i <= (stop - start); i++) { temp[0][i] = exp(temp[0][i] + rpb[shift_score + i]); sum += temp[0][i]; } for(uint i = 0; i <= (stop - start); i++) { temp[0][i] = temp[0][i] / sum; score[shift_score + i] = temp[0][i]; } } barrier(CLK_LOCAL_MEM_FENCE);
结果将保存在依赖系数缓冲区当中。
现在,我们可以将结果系数乘以 Value 实体的相应元素,以便判定多头窗口式自关注模块的结果。
int shift_out = dimension * (u * heads + h) + d; int shift_v = dimension * (heads * (u * 3 + 2) + h); float sum = 0; for(uint i = 0; i <= (stop - start); i++) sum += qkv[shift_v + i] * temp[0][i]; out[shift_out] = sum; }
我们将结果值保存到结果缓冲区的相应元素中,并终止内核。
创建内核之后,我们返回到主程序,在那里我们创建新的 CNeuronDOTOCL 类的方法。首先,我们创建 DOT 方法,在其中将上面创建的内核放置在执行队列当中。
该方法算法非常简单。我们只需将外部参数传递给内核即可。
bool CNeuronDOTOCL::DOT(void) { if(!OpenCL) return false; //--- uint global_work_offset[3] = {0, 0, 0}; uint global_work_size[3] = {iDimension, iUnits, iHeads}; uint local_work_size[3] = {iDimension, 1, 1}; if(!OpenCL.SetArgumentBuffer(def_k_DOTFeedForward, def_k_dot_qkv, cQKV.getOutputIndex())) return false; if(!OpenCL.SetArgumentBuffer(def_k_DOTFeedForward, def_k_dot_score, iScoreBuffer)) return false; if(!OpenCL.SetArgumentBuffer(def_k_DOTFeedForward, def_k_dot_rpb, cRelativePositionsBias.getOutputIndex())) return false; if(!OpenCL.SetArgumentBuffer(def_k_DOTFeedForward, def_k_dot_out, MHAttentionOut.getOutputIndex())) return false;
然后我们将内核发送到执行队列。
ResetLastError(); if(!OpenCL.Execute(def_k_DOTFeedForward, 3, global_work_offset, global_work_size, local_work_size)) { printf("Error of execution kernel %s: %d", __FUNCTION__, GetLastError()); return false; } //--- return true; }
不要忘记控制每个步骤的结果。
完成准备工作之后,我们转到创建 CNeuronDOTOCL::feedForward 方法,在其中我们将为我们的层定义前馈算法。
在方法参数中,我们收到一个指向前一个神经层的指针。为了便于使用,我们将结果指针保存到局部变量当中。
bool CNeuronDOTOCL::feedForward(CNeuronBaseOCL *NeuronOCL)
{
CNeuronBaseOCL* inputs = NeuronOCL;
接下来,我们检查源数据的大小是否与当前层的参数不同。如有必要,我们会缩放源数据,并计算 Query、Key 和 Value 实体。
在数据缓冲区相等的情况下,我们省略缩放步骤,并立即生成 Query、Key 和 Value 实体。
if(iPrevWindowSize != iWindowSize) { if(!cProjInput.FeedForward(inputs) || !cQKV.FeedForward(GetPointer(cProjInput))) return false; inputs = GetPointer(cProjInput); } else if(!cQKV.FeedForward(inputs)) return false;
下一步是调用上面创建的窗口式自关注方法。
if(!DOT()) return false;
降低数据维度。
if(!cProj.FeedForward(GetPointer(MHAttentionOut))) return false;
依据源数据缓冲区添加结果。
if(!SumAndNormilize(inputs.getOutput(), cProj.getOutput(), AttentionOut.getOutput(), iWindowSize, true)) return false;
通过 FeedForward 模块传播结果。
if(!cFF1.FeedForward(GetPointer(AttentionOut))) return false; if(!cFF2.FeedForward(GetPointer(cFF1))) return false;
再次添加缓冲区结果。这次,我们依据窗口式自关注模块的输出添加结果。
if(!SumAndNormilize(AttentionOut.getOutput(), cFF2.getOutput(), SAttenOut.getOutput(), iWindowSize, true)) return false;
在模块的末尾是全局自关注。在这个阶段,我们使用 CNeuronXCiTOCL 层。
if(!cCAtten.FeedForward(GetPointer(SAttenOut))) return false; //--- return true; }
我们检查操作结果,并终止该方法。
研究如何实现类的前馈验算到此结束。接下来,我们转到实现反向传播方法。此处,我们还通过创建窗口化自关注模块的反向传播内核来开始工作:DOTInsideGradients。与前馈内核一样,我们在 3 维任务空间中启动新内核。不过,这次我们不创建局部组。
在参数中,内核接收指向所有必要数据缓冲区的指针。
__kernel void DOTInsideGradients(__global float *qkv, __global float *qkv_g, __global float *scores, __global float *rpb, __global float *rpb_g, __global float *gradient) { //--- init const uint u = get_global_id(0); const uint d = get_global_id(1); const uint h = get_global_id(2); const uint units = get_global_size(0); const uint dimension = get_global_size(1); const uint heads = get_global_size(2);
在内核的主体中,我们在所有 3 个维度中标识线程。我们还判定任务空间,其将指示结果缓冲区的大小。
此处我们还判定数据缓冲区中的偏移量。
uint step = 3 * dimension * heads; uint start = max((int)u - 1, 0); uint stop = min((int)u + 1, (int)units - 1); const uint shift_q = u * step + dimension * h + d; const uint shift_k = u * step + dimension * (heads + h) + d; const uint shift_v = u * step + dimension * (2 * heads + h) + d;
然后我们直接转到梯度分布。首先,我们定义 Value 元素的误差梯度。为此,我们将得到的梯度乘以相应的影响系数。
//--- Calculating Value's gradients float sum = 0; for(uint i = start; i <= stop; i ++) { int shift_score = i * 3 * heads; if(u == i) { shift_score += (uint)(u > 0); } else { if(u > i) shift_score += (uint)(start > 0) + 1; } uint shift_g = dimension * (i * heads + h) + d; sum += gradient[shift_g] * scores[shift_score]; } qkv_g[shift_v] = sum;
下一步是定义 Query 实体的误差梯度。此处的算法稍微复杂一些。我们首先需要判定相关系数的相应向量的误差梯度,并将结果梯度调整为 SoftMax 函数的导数。只有在此之后,我们才能将依赖系数的结果误差梯度乘以 Key 实体张量的相应元素。
请注意,在常规化依赖系数之前,我们添加了位置关注度偏差的元素。如您所知,在添加时,我们会在两个方向上都传递全部梯度。误差计数翻倍很容易被较小的学习系数所抵消。因此,我们将依赖系数矩阵级别的误差梯度传输到位置偏移误差梯度缓冲区。
//--- Calculating Query's gradients float grad = 0; uint shift_score = u * heads * 3; for(int k = start; k <= stop; k++) { float sc_g = 0; float sc = scores[shift_score + k - start]; for(int v = start; v <= stop; v++) for(int dim=0;dim<dimension;dim++) sc_g += scores[shift_score + v - start] * qkv[v * step + dimension * (2 * heads + h) + dim] * gradient[dimension * (u * heads + h) + dim] * ((float)(k == v) - sc); grad += sc_g * qkv[k * step + dimension * (heads + h) + d]; if(d == 0) rpb_g[shift_score + k - start] = sc_g; } qkv_g[shift_q] = grad;
接下来,我们只需要按类似的方式定义 Key 实体的误差梯度。该算法类似于 Query,但它具有不同的系数矩阵维度。
//--- Calculating Key's gradients grad = 0; for(int q = start; q <= stop; q++) { float sc_g = 0; shift_score = q * heads * 3; if(u == q) { shift_score += (uint)(u > 0); } else { if(u > q) shift_score += (uint)(start > 0) + 1; } float sc = scores[shift_score]; for(int v = start; v <= stop; v++) { shift_score = v * heads * 3; if(u == v) { shift_score += (uint)(u > 0); } else { if(u > v) shift_score += (uint)(start > 0) + 1; } for(int dim=0;dim<dimension;dim++) sc_g += scores[shift_score] * qkv[shift_v-d+dim] * gradient[dimension * (v * heads + h) + d] * ((float)(d == v) - sc); } grad += sc_g * qkv[q * step + dimension * h + d]; } qkv_g[shift_k] = grad; }
如此这般,我们就完成了内核的工作,并返回我们的 CNeuronDOTOCL 类的操作,在其中,我们将创建 DOTInsideGradients 方法来调用上述所创建内核。算法维持不变:
- 定义任务空间
bool CNeuronDOTOCL::DOTInsideGradients(void) { if(!OpenCL) return false; //--- uint global_work_offset[3] = {0, 0, 0}; uint global_work_size[3] = {iUnits, iDimension, iHeads};
- 传递参数
if(!OpenCL.SetArgumentBuffer(def_k_DOTInsideGradients, def_k_dotg_qkv, cQKV.getOutputIndex())) return false; if(!OpenCL.SetArgumentBuffer(def_k_DOTInsideGradients, def_k_dotg_qkv_g, cQKV.getGradientIndex())) return false; if(!OpenCL.SetArgumentBuffer(def_k_DOTInsideGradients, def_k_dotg_scores, iScoreBuffer)) return false; if(!OpenCL.SetArgumentBuffer(def_k_DOTInsideGradients, def_k_dotg_rpb, cRelativePositionsBias.getOutputIndex())) return false; if(!OpenCL.SetArgumentBuffer(def_k_DOTInsideGradients, def_k_dotg_rpb_g, cRelativePositionsBias.getGradientIndex())) return false; if(!OpenCL.SetArgumentBuffer(def_k_DOTInsideGradients, def_k_dotg_gradient, MHAttentionOut.getGradientIndex())) return false;
- 放入执行队列
ResetLastError(); if(!OpenCL.Execute(def_k_DOTInsideGradients, 3, global_work_offset, global_work_size)) { printf("Error of execution kernel %s: %d", __FUNCTION__, GetLastError()); return false; } //--- return true; }
- 然后我们检查操作的结果,并终止该方法。
我们直接在 calcInputGradients 方法中描述了反向传播验算算法。在参数中,该方法接收一个指针,其指向上一层的对象,即误差传播的目的地。在方法的主体中,我们立即检查接收到的指针的相关性。因为如果指针无效,我们就无处传递误差梯度。那么所有操作的逻辑含义将接近 “0”。
bool CNeuronDOTOCL::calcInputGradients(CNeuronBaseOCL *prevLayer) { if(!prevLayer) return false;
接下来,我们以相反的顺序重复前馈验算操作。在初始化我们的 CNeuronDOTOCL 类时,我们谨慎地替换缓冲区。现在,当接收到来自后续神经层的误差梯度时,我们直接将其接收到全局关注度层。因此,我们就省略掉已经不必要的数据复制操作,并立即调用全局关注度内层的相关方法。
if(!cCAtten.calcInputGradients(GetPointer(SAttenOut))) return false;
在此,我们还使用了缓冲区替换技术,并立即通过 FeedForward 模块传播误差梯度。
if(!cFF2.calcInputGradients(GetPointer(cFF1))) return false; if(!cFF1.calcInputGradients(GetPointer(AttentionOut))) return false;
接下来,我们对 2 个线程的误差梯度求和。
if(!SumAndNormilize(AttentionOut.getGradient(), SAttenOut.getGradient(), cProj.getGradient(), iWindowSize, false)) return false;
然后我们将其分配给关注度头。
if(!cProj.calcInputGradients(GetPointer(MHAttentionOut))) return false;
调用我们的方法,通过窗口化的自关注模块分配误差梯度。
if(!DOTInsideGradients()) return false;
然后我们检查前一层和当前层的大小。如果我们需要伸缩数据,我们首先将误差梯度传播到伸缩层。我们汇总 2 个线程的误差梯度。只有这样,我们才能将误差梯度伸缩到前一层。
if(iPrevWindowSize != iWindowSize) { if(!cQKV.calcInputGradients(GetPointer(cProjInput))) return false; if(!SumAndNormilize(cProjInput.getGradient(), cProj.getGradient(), cProjInput.getGradient(), iWindowSize, false)) return false; if(!cProjInput.calcInputGradients(prevLayer)) return false; }
如果神经层相等,我们立即将误差梯度传送到前一层。然后我们依据第二个线程的误差梯度来补充它。
else { if(!cQKV.calcInputGradients(prevLayer)) return false; if(!SumAndNormilize(prevLayer.getGradient(), cProj.getGradient(), prevLayer.getGradient(), iWindowSize, false)) return false; } //--- return true; }
所有神经层中误差梯度传播完毕之后,我们需要更新模型参数,将误差最小化。如果不是因为一件事,这里的一切都会很简单。还记得元素位置影响参数缓冲区吗?我们需要更新它的参数。为了执行此功能,我们创建了 RPBUpdateAdam 内核。在参数中,我们将传递指向当前参数和误差梯度缓冲区的指针。我们还传递 Adam 方法的辅助张量和常量。
__kernel void RPBUpdateAdam(__global float *target, __global const float *gradient, __global float *matrix_m, ///<[in,out] Matrix of first momentum __global float *matrix_v, ///<[in,out] Matrix of seconfd momentum const float b1, ///< First momentum multiplier const float b2 ///< Second momentum multiplier ) { const int i = get_global_id(0);
在内核的主体中,我们标识一个线程,它指示数据缓冲区中的偏移量。
接下来,我们声明局部变量,并在其中保存全局缓冲区的必要值。
float m, v, weight; m = matrix_m[i]; v = matrix_v[i]; weight = target[i]; float g = gradient[i];
根据 Adam 方法,我们首先判定动量。
m = b1 * m + (1 - b1) * g; v = b2 * v + (1 - b2) * pow(g, 2);
基于得到的动量,计算参数的必要调整。
float delta = m / (v != 0.0f ? sqrt(v) : 1.0f);
我们将所有数据保存到全局缓冲区的相应元素之中。
target[i] = clamp(weight + delta, -MAX_WEIGHT, MAX_WEIGHT); matrix_m[i] = m; matrix_v[i] = v; }
我们返回到 CNeuronDOTOCL 类,并创建调用内核的 updateRelativePositionsBias 方法。此处我们使用一维任务空间。
bool CNeuronDOTOCL::updateRelativePositionsBias(void) { if(!OpenCL) return false; //--- uint global_work_offset[1] = {0}; uint global_work_size[1] = {cRelativePositionsBias.Neurons()}; if(!OpenCL.SetArgumentBuffer(def_k_RPBUpdateAdam, def_k_rpbw_rpb, cRelativePositionsBias.getOutputIndex())) return false; if(!OpenCL.SetArgumentBuffer(def_k_RPBUpdateAdam, def_k_rpbw_gradient, cRelativePositionsBias.getGradientIndex())) return false; if(!OpenCL.SetArgumentBuffer(def_k_RPBUpdateAdam, def_k_rpbw_matrix_m, cRelativePositionsBias.getFirstMomentumIndex())) return false; if(!OpenCL.SetArgumentBuffer(def_k_RPBUpdateAdam, def_k_rpbw_matrix_v, cRelativePositionsBias.getSecondMomentumIndex())) return false; if(!OpenCL.SetArgument(def_k_RPBUpdateAdam, def_k_rpbw_b1, b1)) return false; if(!OpenCL.SetArgument(def_k_RPBUpdateAdam, def_k_rpbw_b2, b2)) return false; ResetLastError(); if(!OpenCL.Execute(def_k_RPBUpdateAdam, 1, global_work_offset, global_work_size)) { printf("Error of execution kernel %s: %d", __FUNCTION__, GetLastError()); return false; } //--- return true; }
准备工作已就绪。接下来,我们转到创建更新模块参数的顶层方法 updateInputWeights。在参数中,该方法接收指向上一层对象的指针。在这种情况下,我们省略检查收到的指针,因为检查将在内层的方法中执行。
首先,我们检查一下伸缩层参数是否需要更新。如果需要更新,我们在指定层上调用相关方法。
if(iWindowSize != iPrevWindowSize) { if(!cProjInput.UpdateInputWeights(NeuronOCL)) return false; if(!cQKV.UpdateInputWeights(GetPointer(cProjInput))) return false; } else { if(!cQKV.UpdateInputWeights(NeuronOCL)) return false; }
然后,我们更新 Query、Key 和 Value 实体生成层参数。
类似地,我们更新所有内层的参数。
if(!cProj.UpdateInputWeights(GetPointer(MHAttentionOut))) return false; if(!cFF1.UpdateInputWeights(GetPointer(AttentionOut))) return false; if(!cFF2.UpdateInputWeights(GetPointer(cFF1))) return false; if(!cCAtten.UpdateInputWeights(GetPointer(SAttenOut))) return false;
在方法结束时,我们更新位置偏移参数。
if(!updateRelativePositionsBias()) return false; //--- return true; }
同样,我们不应忘记控制每个步骤的结果。
我们对新神经层 CNeuronDOTOCL 方法的研究到此结束。您可以在附件中找到该类、及其方法的完整代码,包括本文中未介绍的方法。
我们转到继续构建新模型的架构。
2.2模型架构
如常,我们将在 CreateDescriptions 方法中描述模型的架构。在参数中,该方法接收指向 3 个动态数组的指针,用于存储模型描述。在方法的主体中,我们立即检查接收到的指针的相关性,并在必要时创建数组的新实例。
bool CreateDescriptions(CArrayObj *dot, CArrayObj *actor, CArrayObj *critic) { //--- CLayerDescription *descr; //--- if(!dot) { dot = new CArrayObj(); if(!dot) return false; } if(!actor) { actor = new CArrayObj(); if(!actor) return false; } if(!critic) { critic = new CArrayObj(); if(!critic) return false; }
我们需要创建 3 个模型:
- DOT
- 扮演者
- 评论者
DOT 模块由 DFFT 架构提供。然而,这里没有关于扮演者或评论者的内容。但我想提醒您,DFFT 方法建议创建一个含有分类和回归输出的 TAE 模块。连续使用扮演者和评论者应该会发出 TAE 模块。扮演者是动作分类器,而评论者是奖励回归。
我们向 DOT 模型饲喂环境当前状态的描述。
//--- DOT dot.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(!dot.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(!dot.Add(descr)) { delete descr; return false; }
然后,我们创建最新数据的嵌入,并将其添加到堆栈之中。
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(!dot.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(!dot.Add(descr)) { delete descr; return false; }
到目前为止,我们已经重复了之前工作中的嵌入架构。然后我们有了变化。我们添加第一个 DOT 模块,其中实现了在单独状态的上下文中进行分析。
//--- layer 4 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronDOTOCL; descr.count = prev_count; descr.window = prev_wout; descr.step = 4; descr.window_out = prev_wout / descr.step; if(!dot.Add(descr)) { delete descr; return false; }
在下一个模块中,我们将数据压缩两倍,但继续在单独状态的上下文中进行分析。
//--- layer 5 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronDOTOCL; descr.count = prev_count; prev_wout = descr.window = prev_wout / 2; descr.step = 4; descr.window_out = prev_wout / descr.step; if(!dot.Add(descr)) { delete descr; return false; }
接下来,我们将要分析的数据分为 2 个连续状态。
//--- layer 6 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronDOTOCL; prev_count = descr.count = prev_count / 2; prev_wout = descr.window = prev_wout * 2; descr.step = 4; descr.window_out = prev_wout / descr.step; if(!dot.Add(descr)) { delete descr; return false; }
我们再次压缩数据。
//--- layer 7 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronDOTOCL; descr.count = prev_count; prev_wout = descr.window = prev_wout / 2; descr.step = 4; descr.window_out = prev_wout / descr.step; if(!dot.Add(descr)) { delete descr; return false; }
DOT 模型的最后一层超越了 DFFT 方法。在此,我添加了一个交叉关注度层。
//--- layer 8 if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronMH2AttentionOCL; descr.count = prev_wout; descr.window = prev_count; descr.step = 4; descr.window_out = prev_wout / descr.step; descr.optimization = ADAM; if(!dot.Add(descr)) { delete descr; return false; }
扮演者模型接收的输入,是经环境状态的 DOT 模型中处理后的内容。
//--- Actor actor.Clear(); //--- Input layer if(!(descr = new CLayerDescription())) return false; descr.type = defNeuronBaseOCL; descr.count = prev_count*prev_wout; 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 * prev_wout; descr.step = AccountDescr; descr.optimization = ADAM; descr.activation = SIGMOID; if(!actor.Add(descr)) { delete descr; return false; }
数据由 2 个全连接层处理。
//--- 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; }
评论者还使用处理过的环境状态作为输入。
//--- Critic critic.Clear(); //--- Input layer if(!(descr = new CLayerDescription())) return false; descr.Copy(actor.At(0)); if(!critic.Add(descr)) { delete descr; return false; }
我们用个体的动作来补充环境状态的描述。
//--- layer 1 if(!(descr = new CLayerDescription())) return false; descr.Copy(actor.At(0)); descr.step = NActions; descr.optimization = ADAM; descr.activation = SIGMOID; if(!critic.Add(descr)) { delete descr; return false; }
数据由 2 个完全连接的层处理,输出处有一个奖励向量。
//--- layer 2 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 3 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.3环境交互 EA
在创建模型架构之后,我们转到创建一款与环境交互的 EA “...\Experts\DFFT\Research.mq5”。该 EA 旨在收集初始训练样本,然后更新经验回放缓冲区。EA 还可用于测试经过训练的模型。尽管还提供了另一个 EA “...\Experts\DFFT\Test.mq5” 来执行此功能。两个 EA 都有类似的算法。不过,后者不会将数据保存到经验回放缓冲区以供后续训练。这样做是为了对经过训练的模型进行 “公平” 测试。
这两个 EA 主要复制自以前的作品。在本文的框架内,我们将只关注与模型细节相关的变化。
在收集数据时,我们不会使用评论者模型。
CNet DOT; CNet Actor;
在 EA 初始化方法中,我们首先连接必要的指标。
//+------------------------------------------------------------------+ //| Expert initialization function | //+------------------------------------------------------------------+ int OnInit() { //--- if(!Symb.Name(_Symbol)) return INIT_FAILED; Symb.Refresh(); //--- if(!RSI.Create(Symb.Name(), TimeFrame, RSIPeriod, RSIPrice)) return INIT_FAILED; //--- if(!CCI.Create(Symb.Name(), TimeFrame, CCIPeriod, CCIPrice)) return INIT_FAILED; //--- if(!ATR.Create(Symb.Name(), TimeFrame, ATRPeriod)) return INIT_FAILED; //--- if(!MACD.Create(Symb.Name(), TimeFrame, FastPeriod, SlowPeriod, SignalPeriod, MACDPrice)) return INIT_FAILED; if(!RSI.BufferResize(HistoryBars) || !CCI.BufferResize(HistoryBars) || !ATR.BufferResize(HistoryBars) || !MACD.BufferResize(HistoryBars)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); return INIT_FAILED; } //--- if(!Trade.SetTypeFillingBySymbol(Symb.Name())) return INIT_FAILED; //--- load models float temp;
然后我们尝试加载预训练的模型。
if(!DOT.Load(FileName + "DOT.nnw", temp, temp, temp, dtStudied, true) || !Actor.Load(FileName + "Act.nnw", temp, temp, temp, dtStudied, true)) { CArrayObj *dot = new CArrayObj(); CArrayObj *actor = new CArrayObj(); CArrayObj *critic = new CArrayObj(); if(!CreateDescriptions(dot, actor, critic)) { delete dot; delete actor; delete critic; return INIT_FAILED; } if(!DOT.Create(dot) || !Actor.Create(actor)) { delete dot; delete actor; delete critic; return INIT_FAILED; } delete dot; delete actor; delete critic; }
如果无法加载模型,我们使用随机参数初始化新模型。之后,我们将两个模型传输到单个 OpenCL 关联环境中。
Actor.SetOpenCL(DOT.GetOpenCL());
我们还对模型架构进行最低限度的检查。
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; } //--- DOT.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; }
将余额状态保存在局部变量之中。
PrevBalance = AccountInfoDouble(ACCOUNT_BALANCE); PrevEquity = AccountInfoDouble(ACCOUNT_EQUITY); //--- return(INIT_SUCCEEDED); }
与环境和数据收集的交互在 OnTick 方法中实现。在方法主体中,我们首先检查新柱线开盘事件的发生。任何分析都只在新蜡烛上执行。
//+------------------------------------------------------------------+ //| Expert tick function | //+------------------------------------------------------------------+ void OnTick() { //--- if(!IsNewBar()) return;
接下来,我们更新历史数据。
int bars = CopyRates(Symb.Name(), TimeFrame, iTime(Symb.Name(), TimeFrame, 1), HistoryBars, Rates); if(!ArraySetAsSeries(Rates, true)) return; //--- RSI.Refresh(); CCI.Refresh(); ATR.Refresh(); MACD.Refresh(); Symb.Refresh(); Symb.RefreshRates();
填充缓冲区以描述环境的状态。
float atr = 0; for(int b = 0; b < (int)HistoryBars; b++) { float open = (float)Rates[b].open; float rsi = (float)RSI.Main(b); float cci = (float)CCI.Main(b); atr = (float)ATR.Main(b); float macd = (float)MACD.Main(b); float sign = (float)MACD.Signal(b); if(rsi == EMPTY_VALUE || cci == EMPTY_VALUE || atr == EMPTY_VALUE || macd == EMPTY_VALUE || sign == EMPTY_VALUE) continue; //--- int shift = b * BarDescr; 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; } bState.AssignArray(sState.state);
下一步是收集有关当前账户状态的数据。
sState.account[0] = (float)AccountInfoDouble(ACCOUNT_BALANCE); sState.account[1] = (float)AccountInfoDouble(ACCOUNT_EQUITY); //--- double buy_value = 0, sell_value = 0, buy_profit = 0, sell_profit = 0; double position_discount = 0; double multiplyer = 1.0 / (60.0 * 60.0 * 10.0); int total = PositionsTotal(); datetime current = TimeCurrent(); for(int i = 0; i < total; i++) { if(PositionGetSymbol(i) != Symb.Name()) continue; double profit = PositionGetDouble(POSITION_PROFIT); switch((int)PositionGetInteger(POSITION_TYPE)) { case POSITION_TYPE_BUY: buy_value += PositionGetDouble(POSITION_VOLUME); buy_profit += profit; break; case POSITION_TYPE_SELL: sell_value += PositionGetDouble(POSITION_VOLUME); sell_profit += profit; break; } position_discount += profit - (current - PositionGetInteger(POSITION_TIME)) * multiplyer * MathAbs(profit); } sState.account[2] = (float)buy_value; sState.account[3] = (float)sell_value; sState.account[4] = (float)buy_profit; sState.account[5] = (float)sell_profit; sState.account[6] = (float)position_discount; sState.account[7] = (float)Rates[0].time;
我们将所收集数据合并到描述帐户状态的缓冲区之中。
bAccount.Clear(); bAccount.Add((float)((sState.account[0] - PrevBalance) / PrevBalance)); bAccount.Add((float)(sState.account[1] / PrevBalance)); bAccount.Add((float)((sState.account[1] - PrevEquity) / PrevEquity)); bAccount.Add(sState.account[2]); bAccount.Add(sState.account[3]); bAccount.Add((float)(sState.account[4] / PrevBalance)); bAccount.Add((float)(sState.account[5] / PrevBalance)); bAccount.Add((float)(sState.account[6] / PrevBalance));
此处,我们添加当前状态的时间戳。
double x = (double)Rates[0].time / (double)(D'2024.01.01' - D'2023.01.01'); bAccount.Add((float)MathSin(2.0 * M_PI * x)); x = (double)Rates[0].time / (double)PeriodSeconds(PERIOD_MN1); bAccount.Add((float)MathCos(2.0 * M_PI * x)); x = (double)Rates[0].time / (double)PeriodSeconds(PERIOD_W1); bAccount.Add((float)MathSin(2.0 * M_PI * x)); x = (double)Rates[0].time / (double)PeriodSeconds(PERIOD_D1); bAccount.Add((float)MathSin(2.0 * M_PI * x)); //--- if(bAccount.GetIndex() >= 0) if(!bAccount.BufferWrite()) return;
收集初始数据后,我们执行编码器的前馈验算。
if(!DOT.feedForward((CBufferFloat*)GetPointer(bState), 1, false, (CBufferFloat*)NULL)) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); return; }
我们立即实现扮演者的前馈验算。
//--- Actor if(!Actor.feedForward((CNet *)GetPointer(DOT), -1, (CBufferFloat*)GetPointer(bAccount))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); return; }
接收模型结果。
PrevBalance = sState.account[0]; PrevEquity = sState.account[1]; //--- vector<float> temp; Actor.getResults(temp); if(temp.Size() < NActions) temp = vector<float>::Zeros(NActions);
在执行交易操作时解码它们。
double min_lot = Symb.LotsMin(); double step_lot = Symb.LotsStep(); double stops = MathMax(Symb.StopsLevel(), 1) * Symb.Point(); if(temp[0] >= temp[3]) { temp[0] -= temp[3]; temp[3] = 0; } else { temp[3] -= temp[0]; temp[0] = 0; } //--- buy control if(temp[0] < min_lot || (temp[1] * MaxTP * Symb.Point()) <= stops || (temp[2] * MaxSL * Symb.Point()) <= stops) { if(buy_value > 0) CloseByDirection(POSITION_TYPE_BUY); } else { double buy_lot = min_lot + MathRound((double)(temp[0] - min_lot) / step_lot) * step_lot; double buy_tp = NormalizeDouble(Symb.Ask() + temp[1] * MaxTP * Symb.Point(), Symb.Digits()); double buy_sl = NormalizeDouble(Symb.Ask() - temp[2] * MaxSL * Symb.Point(), Symb.Digits()); if(buy_value > 0) TrailPosition(POSITION_TYPE_BUY, buy_sl, buy_tp); if(buy_value != buy_lot) { if(buy_value > buy_lot) ClosePartial(POSITION_TYPE_BUY, buy_value - buy_lot); else Trade.Buy(buy_lot - buy_value, Symb.Name(), Symb.Ask(), buy_sl, buy_tp); } }
//--- sell control if(temp[3] < min_lot || (temp[4] * MaxTP * Symb.Point()) <= stops || (temp[5] * MaxSL * Symb.Point()) <= stops) { if(sell_value > 0) CloseByDirection(POSITION_TYPE_SELL); } else { double sell_lot = min_lot + MathRound((double)(temp[3] - min_lot) / step_lot) * step_lot; double sell_tp = NormalizeDouble(Symb.Bid() - temp[4] * MaxTP * Symb.Point(), Symb.Digits()); double sell_sl = NormalizeDouble(Symb.Bid() + temp[5] * MaxSL * Symb.Point(), Symb.Digits()); if(sell_value > 0) TrailPosition(POSITION_TYPE_SELL, sell_sl, sell_tp); if(sell_value != sell_lot) { if(sell_value > sell_lot) ClosePartial(POSITION_TYPE_SELL, sell_value - sell_lot); else Trade.Sell(sell_lot - sell_value, Symb.Name(), Symb.Bid(), sell_sl, sell_tp); } }
我们把从环境接收的数据保存到经验回放缓冲区之中。
sState.rewards[0] = bAccount[0]; sState.rewards[1] = 1.0f - bAccount[1]; if((buy_value + sell_value) == 0) sState.rewards[2] -= (float)(atr / PrevBalance); else sState.rewards[2] = 0; for(ulong i = 0; i < NActions; i++) sState.action[i] = temp[i]; if(!Base.Add(sState)) ExpertRemove(); }
EA 的其余方法均已原封不动地转移。您可以在附件中找到它们。
2.4模型训练 EA
收集训练数据集后,我们转到构建模型训练 EA “...\Experts\DFFT\Study.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 batch = GPTBars + 48; int state = (int)((MathRand() * MathRand() / MathPow(32767, 2)) * (Buffer[tr].Total - 2 - PrecoderBars - batch)); if(state <= 0) { iter--; continue; }
之后,我们清除模型的内部堆栈。
DOT.Clear();
创建嵌套循环,从经验回放缓冲区中提取连续的历史状态,以便训练模型。我们设置模型训练批次,比模型内部轨迹的深度多 2 天。
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(!DOT.feedForward((CBufferFloat*)GetPointer(bState), 1, false, (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((CNet *)GetPointer(DOT), -1, (CBufferFloat*)GetPointer(bAccount))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; } //--- Critic if(!Critic.feedForward((CNet *)GetPointer(DOT), -1, (CNet*)GetPointer(Actor))) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
接下来,我们训练扮演者从经验回放缓冲区执行动作,将梯度传送到编码器模型。遵照 DFFT 方法的建议,在 TAE 模块中进行对象分类。
Result.AssignArray(Buffer[tr].States[i].action); if(!Actor.backProp(Result, (CBufferFloat *)GetPointer(bAccount), (CBufferFloat *)GetPointer(bGradient)) || !DOT.backPropGradient((CBufferFloat*)NULL) ) { 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); if(!Critic.backProp(Result, (CNet *)GetPointer(Actor)) || !DOT.backPropGradient((CBufferFloat*)NULL) || !Actor.backPropGradient((CBufferFloat *)GetPointer(bAccount), (CBufferFloat *)GetPointer(bGradient)) || !DOT.backPropGradient((CBufferFloat*)NULL) ) { PrintFormat("%s -> %d", __FUNCTION__, __LINE__); Stop = true; break; }
注意,在许多算法中,我们之前试图避免模型的相互拟合。因此,我们试图避免不良结果。与之对比,DFFT 方法的作者指出,这种方式将允许更好地配置编码器参数,以便提取最多信息。
训练模型后,我们会告知用户训练过程的进度,并转到循环的下一次迭代。
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(); } } }
训练过程的所有迭代成功完成后,我们清除图表上的注释字段。学习结果将打印在日记中。然后我们启动 EA 的完结。
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 方法的回顾到此结束。您可以在附件中找到 EA,及其所有方法的完整代码。附件还包含本文中用到的所有程序。
3. 测试
我们已经做了大量工作来利用 MQL5 实现无解码器完全基于变换器(DFFT) 方法。现在是本文的第 3 部分:测试已完成的工作。如前,新模型依据 EURUSD H1 的历史数据进行训练和测试。指标均采用默认参数。
为了训练模型,我们在 2023 年前 7 个月的时间段内收集了 500 条随机轨迹。训练后的模型依据 2023 年 8 月的历史数据进行了测试。因此,测试区间不包括在训练集之中。这允许依据新数据进行性能评估。
我必须承认,该模型在训练过程中、和测试期间的操作模式下,所消耗的计算资源都非常 “轻量”。
学习过程相当稳定,扮演者和评论者的误差都平滑地降低。在训练过程中,我们获得了一个能够在训练和测试数据上产生微薄盈利的模型。不过,它应能做得更好,从而得到更高水平盈利能力,以及更均匀的余额线。
结束语
在本文中,我们领略了 DFFT 方法,这是一种基于无解码变换器的有效对象检测器,用于解决计算机视觉问题。该方式的主要特征包括使用变换器对单个特征映射进行特征提取和密集预测。该方法提供了新的模块来提高模型训练和操作的效率。
该方法的作者已验明,DFFT 以相对较低的计算成本提供了高精度的对象检测。
在本文的实践部分,我们利用 MQL5 实现了所提议的方式。我们依据真实的历史数据训练和测试了所构建模型。所获得的结果确认了所提算法的有效性,值得进行更详细的实践研究。
我想提醒您,本文中讲述的所有程序仅供参考。创建它们只是为了演示所提议的方法及其能力。任何程序用于真实金融市场之前,请确保完成并彻底测试它们。
参考
文中所用程序
# | 已发行 | 类型 | 说明 |
---|---|---|---|
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/14338



