English Русский Español Deutsch 日本語 Português
preview
神经网络变得简单(第 58 部分):决策转换器(DT)

神经网络变得简单(第 58 部分):决策转换器(DT)

MetaTrader 5交易系统 | 21 五月 2024, 09:36
490 0
Dmitriy Gizlyk
Dmitriy Gizlyk

概述

在本系列中,我们已验证了相当广泛的不同强化学习算法。它们都使用基础方式:

  1. 智能体分析环境的当前状态。
  2. 采取最优动作(在学习的政策 — 行为策略的框架内)。
  3. 转入环境的新状态。
  4. 从环境中获得完全过渡到新状态的奖励。

该序列基于马尔可夫(Markov)过程的原理。假设起点是环境的当前状态。摆脱这种状态只有一种最优方法,它不依赖以前的路径。

我想讲述另一种替代方式,它是由谷歌团队在文章《决策转换器:通过序列建模进行强化学习》(2021 年 6 月 2 日)中提出。这项工作的主要亮点是将强化学习问题投影到条件化动作序列的建模,条件化则依据所需奖励的自回归模型。


1. 决策转换器方法特点

决策转换器是一种架构,它改变了我们看待强化学习的方式。与选择智能体动作的经典方法对比,决策制定序列问题的研究是在语言建模的框架内。

该方法的作者建议依据先前执行的动作和访问状态的上下文构建智能体的动作轨迹,就像语言模型依据普通文本的上下文构建句子(单词序列)一样。以这种方式处置问题,允许使用各种语言模型工具,只需进行最少的修改,包括 GPT(生成式预训练转换器)。

可能值得从构造智能体轨迹的原则开始。在这种情况下,我们正在专门谈论构建轨迹,而不是一系列动作。

选择轨迹表示时的需求之一是使用转换器的能力,这将允许人们在源数据中提取重要形态。除了对环境条件的描述外,还有就是智能体执行的动作和奖励。方法作者在此提供了一种相当有趣的方式来建模奖励。我们希望模型基于未来期望的奖励来生成动作,而非过去的奖励。毕竟,我们的愿望是达成一些目标。作者没有直接提供奖励,取而代之提供了一个“在途回报(Return-To-Go)”量级模型。这类似于直至剧集结束时的累积奖励。不过,我们表示的是期望的结果,而非实际的结果。

这会产生以下轨迹表象,适用于自回归学习和生成:

在测试已训练模型时,我们可以指定所需的奖励(例如,1 代表成功,或 0 代表失败),以及环境的初始状态,作为触发生成的信息。执行为当前状态生成的动作之后,我们依据环境中接收到的数额降低目标奖励,并重复该过程,直到获得所需的总奖励,或世代完成。

请注意,如果您采用这种方式,并在达到所需的总奖励水平后继续,则也许会将负值传递给“在途回报”。这也许会导致损失。

为了让智能体制定决策,我们将最后 K 时间步骤作为源数据传递给决策转换器。总共有 3*K 个令牌。每种模态一个:在途回报、状态、和导致该状态的动作。为了获得令牌的向量表示,该方法的作者对每种模态都使用经过训练且完全连接神经层,其会将源数据投射到向量表示的维度之中。之后对图层进行常规化。在分析复杂(复合)环境状态的情况下,可以使用卷积编码器,替代完全连接神经层。

此外,对于每个时间步骤,都会训练时间戳的向量表示,并将其添加到每个令牌之中。这种方法与转换器中的标准定位向量表示不同,因为一个时间步骤对应若干个令牌(在给定的示例中,有三个令牌)。然后使用 GPT 模型处理令牌,其用自回归建模预测未来的动作令牌。在研究监督训练方法时,我们在《A take on GPT》一文中更多地讨论了 GPT 模型的架构。

也许看似很奇怪,但模型训练过程是使用监督学习方法构建的。首先,我们安排与环境的交互,并对一组随机轨迹进行采样。我们已经多次如此做了。之后运作离线训练。我们从收集的轨迹集中选择 K 长度的迷你包。对应于 st 输入令牌的预测头学习预测 at 动作 — 即可对离散动作使用交叉熵损失函数,亦可对连续动作使用均方误差。每个时间步骤的损失求均值。

然而,在实验期间,该方法的作者并未发现预测后续状态或奖励可以提高模型的效率。

以下是作者对该方法的可视化。

DT 架构

我不会详细讨论变换器的架构,和自关注机制,因为这些主题之前都已经讨论过了。我们转入实际部分,看看利用 MQL5 实现决策转换器机制。


2. 利用 MQL5 实现

在简要介绍了决策转换器方法的理论层面之后,我们转入利用 MQL5 实现它。我们要面对的第一件事是实现源数据实体的嵌入问题。在解决监督学习方法中的类似问题时,我们曾用过卷积层,其步长等于原始数据的窗口。但在这种情况下,有两个难题在等着我们:

  1. 环境状态描述向量的大小与动作空间向量不同。奖励向量具有第三个大小。
  2. 所有实体都包含来自不同分布渠道的源数据。需要不同的嵌入矩阵才能在单个空间中将它们转换为可比较的形式。

我们将环境状态分为两个内容和大小完全不同的区块:价格走势的历史数据,和账户当前状态的描述。这增加了另一种模态供分析。在新的实验中,也许会出现可供分析的额外数据。显然,以这种条件,我们不能使用卷积层,我们需要另一个通用解决方案,能够在大小为 [n1, n2, n3,...,nN] 的向量里嵌入 N 个模态。如上所述,方法作者对每种模态都使用了经过训练的完全连接层。这种方式非常普遍,但在我们的案例中,它意味要放弃若干种模态的并行处理。

在这种情况下,在我看来,最优的解决方案是以神经嵌入层 CNeuronEmbeddingOCL 的形式创建一个新对象。这是允许我们正确构建流程的唯一方法。不过,在创建新类的对象和功能之前,我们仍然需要决定其架构的一些特性。

在前向验算的每次迭代中,我们计划传输五个源数据向量:

  1. 历史价格走势数据。
  2. 帐户的状态。
  3. 奖励。
  4. 在上一步中采取的动作。
  5. 时间戳。

如您所见,来自不同模态的信息在内容和数据量上有很大差异。我们必须检测将源数据传输到嵌入层的技术。由于数据向量的大小不同,不可能为每种模态使用拥有各自行或列的矩阵。当然,我们可以使用向量的动态数组。但这个选项只有在利用 MQL5 实现框架内才有可能。不过,我们很难将这样的数组传递给 OpenCL 关联环境,以便并行计算。为不同数量的源数据模态创建各自的内核将令程序复杂化,并且无法令算法完全通用。每个单独模态各用一个内核会导致它们的顺序嵌入,并限制了并行计算的可能性。

在我看来,在这种状况下,最通用的解决方案应当是用两个向量(缓冲区)。在其中一个向量中,我们一致地指示所有源数据。在第二个当中,按照每个序列窗口大小的形式提供了“数据映射”。因此,仅使用两个缓冲区,我们就能够将任意数量的具有独立数据大小的模态传输到内核,而无需更改内核内的动作算法。这是一个完全通用的解决方案,拥有嵌入所有模态同时并行计算的能力。

不光具有简单性和多功能性,这种方式还允许我们轻松地将新类与所有先前创建的神经层结合起来。

我们解决了传输原始数据的问题。但我们在权重矩阵上也遇到几乎类似的状况。正如我们已经提到的,每种模态都需要自己的嵌入矩阵。不过,在这种情况下,我们有一个优势 — 所有模态的嵌入大小都是相等的。毕竟,嵌入过程的目标是将不同的模态转换为可比较的形式。因此,源数据的每个元素都具有相同数量的加权系数,以便将数据传输到神经层的输出。这允许我们使用一个公用矩阵来存储所有模态的嵌入权重。矩阵的列数等于一种模态的嵌入大小。矩阵行数将等于源数据的总数。在此,我们可以添加贝叶斯(Bayesian)偏置元素,这会在权重系数矩阵里为每种模态加上一行。

下一个我想讨论的建设性观点是嵌入整个以前序列的相关性。我不质疑智能体分析先前轨迹的必要性。毕竟,这是所研究方法的基础。但我们要从大局看待这个问题。决策转换器本质上是一个自回归模型,它接收 K*N 个令牌作为输入。在每个时间步骤,只保持 N 个新令牌。其余 (K-1)*N 个令牌完全重复上一个时间步骤所用令牌。当然,在训练的初始阶段,由于针对嵌入矩阵所做的更改,即使是重复的源数据也会有不同的嵌入。但模型经训练后,这种影响会降低。在日常操作中,当权重矩阵不变时,这种偏差是完全不存在的。在每个时间步骤仅嵌入新的源数据是很合乎逻辑的。这允许我们显著降低模型训练和日常操作期间数据嵌入的资源成本。

另外,我们再注意一点 — 定位编码。在我们的任务中,历史数据的位置由柱线的开盘时间指示。我们在源数据模型中包含了时间戳编码。但方法作者在嵌入其它模态时添加了定位令牌。该解决方案与转换器架构完全一致,但往动作序列里添加了额外的操作。我们将创建一个时间戳嵌入,并将其添加为单独的模态,因为定位嵌入可以与其它模态的嵌入并行完成。不过,这种方式会增加所分析的数据量。在每种情况下,在选择定位编码方法时,您需要考虑程序的各种因素间的平衡。

在定义了我们欲实现的主要设计特性之后,我们就能转入构建 OpenCL 程序。我们将一如既往地从构建一个前向验算内核开始。我们打算得到一个嵌入矩阵。该矩阵的每一行将代表一个单独模态的嵌入。同样,我们将形成一个内核问题的二维空间。在一个维度中,我们指示一种模态的嵌入大小。在第二个维度中,我们指示所分析的模态数量。

您也许还记得,我们决定在序列中只嵌入最后的模态。我们传输先前数据的嵌入,而不会更改先前获得的结果。同时,我们在 CNeuronEmbeddingOCL 层的输出端接收整个序列的嵌入。

在内核参数中,我们传递 5 个数据缓冲区的指针,和 1 个常量,其中我们指示序列的大小。在这种情况下,我们所说的序列大小是指所分析历史数据的步骤数量。

在数据缓冲区中,我们将传递以下信息:

  • inputs ― 所有模态序列形式的初始数据(1 个时间步骤);
  • outputs — 对应所分析历史深度的所有模态的嵌入序列;
  • weights — 权重比矩阵;
  • windows — 源数据映射(源数据中每种模态的数据窗口大小);
  • std — 标准差向量(用于常规化嵌入)。
__kernel void Embedding(__global float *inputs,
                        __global float *outputs,
                        __global float *weights,
                        __global int   *windows,
                        __global float *std,
                        const int stack_size
                       )
  {
   const int window_out = get_global_size(0);
   const int pos = get_local_id(0);
   const int emb = get_global_id(1);
   const int emb_total = get_global_size(1);
   const int shift_out = emb * window_out + pos;
   const int step = emb_total * window_out;
   const uint ls = min((uint)get_local_size(0), (uint)LOCAL_ARRAY_SIZE);

在内核主体中,我们辨别两个维度的流,并在数据缓冲区中定义偏移常量。然后,我们在结果缓冲区中顺移先前获得的嵌入。请注意,每个线程中只传输一个嵌入位置。这允许在并行线程中安排数据复制。

   for(int i=stack_size-1;i>0;i--)
      outputs[i*step+shift_out]=outputs[(i-1)*step+shift_out];

下一步是检测正在分析的模态于源数据缓冲区中的偏移量。为此,我们数一数所分析数据之前源数据缓冲区中的模态元素总数。

   int shift_in = 0;
   for(int i = 0; i < emb; i++)
      shift_in += windows[i];

此处,我们判定权重矩阵缓冲区中的偏移量,同时考虑贝叶斯元素。

   const int shift_weights = (shift_in + emb) * window_out;

我们将当前模态的源数据窗口的大小保存到局部变量之中,并为操控局部数组定义常量。

   const int window_in = windows[emb];
   const int local_pos = (pos >= ls ? pos % (ls - 1) : pos);
   const int local_orders = (window_out + ls - 1) / ls;
   const int local_order = pos / ls;

创建一个局部数组,并用零值填充它。此处,我们将为局部线程同步设置一个栅栏。

   __local float temp[LOCAL_ARRAY_SIZE];
   if(local_order == 0)
      temp[local_pos] = 0;
   barrier(CLK_LOCAL_MEM_FENCE);

至此,可以认为准备工作已经完毕,我们直接处置嵌入操作。首先,我们将所分析模态的输入数据向量乘以相应的权重比向量。以这种方式,我们就可以得到我们需要的嵌入元素。

   float value = weights[shift_weights + window_in];
   for(int i = 0; i < window_in; i++)
      value += inputs[shift_in + i] * weights[shift_weights + i];

在这种情况下,我们不使用激活函数,因为我们需要在所需的子空间中获取序列的每个元素的投影。不过,我们知道这种方式并不能保证不同源数据嵌入的可比性。因此,下一步是在单一模态的嵌入中对数据进行规范化。因此,我们将所有嵌入的数据降至零到单位方差间的均值。我来提醒您常规化方程。

常规化

为此,我们将首先遍历局部数组收集所分析嵌入的所有元素的总和。将结果量除以嵌入向量的大小。如此这般,我们就能判定平均值。然后我们将当前嵌入元素的值调整为平均值。我们使用栅栏来同步本地线程。

   for(int i = 0; i < local_orders; i++)
     {
      if(i == local_order)
         temp[local_pos] += value;
      barrier(CLK_LOCAL_MEM_FENCE);
     }
//---
   int count = ls;
   do
     {
      count = (count + 1) / 2;
      if(pos < count)
         temp[pos] += temp[pos + count];
      barrier(CLK_LOCAL_MEM_FENCE);
     }
   while(count > 1);
//---
   value -= temp[0] / (float)window_out;
   barrier(CLK_LOCAL_MEM_FENCE);

此处值得说几句话,即针对所执行操作的衍生值。如您所知,我们使用前向验算函数的衍生值来传播向后验算期间的误差梯度。当从变量中求和或减去常数时,我们将完整的误差梯度传递给变量。不过,这种状况的细微差别在于我们正在减去平均值。反过来,它被用作所分析变量的函数,且具有其衍生值。为了准确分布误差梯度,我们需要将其传递给均值函数的衍生值。这个陈述对于标准差也成立,我们以后会用到。但我的个人经验表明,经由均值和方差函数传递的衍生值,其总误差梯度比变量本身的误差梯度小若干倍。为了节省资源,我现在不会为了存储过渡数据,以及后续计算该方向的误差梯度,而将算法复杂化。

现在我们回到我们的内核算法。在这个阶段,我们已经将嵌入向量带至零均值。是时候将其减少到单位方差了。为此,我们将所分析嵌入的所有元素除以其标准差,其为我们据局部数组计算得出。

我提醒您,局部数组用于在局部线程组之间传输数据。线程的同步是通过栅栏运作的。

   if(local_order == 0)
      temp[local_pos] = 0;
   barrier(CLK_LOCAL_MEM_FENCE);
//---
   for(int i = 0; i < local_orders; i++)
     {
      if(i == local_order)
         temp[local_pos] += pow(value,2.0f) / (float)window_out;
      barrier(CLK_LOCAL_MEM_FENCE);
     }
//---
   count = ls;
   do
     {
      count = (count + 1) / 2;
      if(pos < count)
         temp[pos] += temp[pos + count];
      barrier(CLK_LOCAL_MEM_FENCE);
     }
   while(count > 1);
//---
   if(temp[0] > 0)
      value /= sqrt(temp[0]);

现在我们只需要将接收到的值保存到结果缓冲区的相应元素当中。还有,不要忘记保存计算出的标准差,这是作为后续逆向验算期间的误差梯度分布。

   outputs[shift_out] = value;
   if(pos == 0)
      std[emb] = sqrt(temp[0]);
  }

前向验算内核的工作完成后,我建议转入分析误差梯度分布内核算法。我们已经开始讨论通过上面的数据归一化函数计算误差梯度的分布。为了优化资源的使用,决定通过嵌入向量的平均值和离散函数来简化误差梯度方面的算法。在此阶段,我们将均值和方差视为常数。正是在这种范式中,构建了 EmbeddingHiddenGradient 误差梯度核算法。

在内核参数中,我们传递 5 个数据缓冲区,和 1 个常量。我们已经熟悉了上一个内核中使用的常量和 3 个缓冲区。原始数据和结果的缓冲区被替换为相应误差梯度的缓冲区。

__kernel void EmbeddingHiddenGradient(__global float *inputs_gradient,
                                      __global float *outputs_gradient,
                                      __global float *weights,
                                      __global int   *windows,
                                      __global float *std,
                                      const int window_out
                                     )
  {
   const int pos = get_global_id(0);

我们将根据源数据的元素数量在一维任务空间中调用内核。在内核的主体中,我们立即识别当前线程。不过,元素在源数据缓冲区中的位置并不能让我们明确了解结果缓冲区中的依赖元素。因此,我们首先遍历原始数据映射缓冲区,以便检测要分析的模态。

   int emb = -1;
   int count = 0;
   do
     {
      emb++;
      count += windows[emb];
     }
   while(count <= pos);

 基于所分析模态的索引,我们判定结果和权重缓冲区的偏离。

   const int shift_out = emb * window_out;
   const int shift_weights = (pos + emb) * window_out;

在判定数据缓冲区的偏离后,我们从结果缓冲区的所有依赖元素中收集误差梯度,并在常规化之前根据嵌入向量的标准差对其进行调整。我提醒您,我们在直接验算期间将其值保存在 std 缓冲区当中。

   float value = 0;
   for(int i = 0; i < window_out; i++)
      value += outputs_gradient[shift_out + i] * weights[shift_weights + i];
   float s = std[emb];
   if(s > 0)
      value /= s;
//---
   inputs_gradient[pos] = value;
  }

结果值存储在前一层的梯度缓冲区之中。

为了配合 OpenCL 程序工作,我们只需要研究更新权重矩阵的内核算法。在本文中,我们仅查看我最常用的 Adam 方法的内核。此内核与前面讨论的类似内核之间的主要区别在于数据缓冲区中偏移量的判定。这是意料之中的。对于权重比更新方法本身的算法,我们并未进行根本性的改变。

__kernel void EmbeddingUpdateWeightsAdam(__global float *weights,
                                          __global const float *gradient,
                                          __global const float *inputs,   
                                          __global float *matrix_m,       
                                          __global float *matrix_v,       
                                          __global int   *windows,
                                          __global float *std,
                                          const int window_out,
                                          const float l,                  
                                          const float b1,               
                                          const float b2                
                                        )
  {
   const int i = get_global_id(0);

在内核参数中传递了相当多的缓冲区和常量。我们已经知道所有这些。基于权重比缓冲区中的元素数量在一维任务空间中调用内核。

在内核主体中,我们如往常一样,依据线程 ID 来识别正在分析的缓冲区元素。之后,我们判定数据缓冲区中与所需元素的偏移量。

   int emb = -1;
   int count = 0;
   int shift = 0;
   do
     {
      emb++;
      shift = count;
      count += (windows[emb] + 1) * window_out;
     }
   while(count <= i);
   const int shift_out = emb * window_out;
   int shift_in = shift / window_out - emb;
   shift = (i - shift) / window_out;

然后我们安排对权重比的调整。该过程完全重复了本系列前几篇文章中曾讨论的过程。将结果和必要的数据保存到相应的缓冲区之中。

   float weight = weights[i];
   float g = gradient[shift_out] * inp / std[emb];
   float mt = b1 * matrix_m[i] + (1 - b1) * g;
   float vt = b2 * matrix_v[i] + (1 - b2) * pow(g, 2);
   float delta = l * (mt / (sqrt(vt) + 1.0e-37f) - (l1 * sign(weight) + l2 * weight));
   if(delta * g > 0)
      weights[i] = clamp(weights[i] + delta, -MAX_WEIGHT, MAX_WEIGHT);
   matrix_m[i] = mt;
   matrix_v[i] = vt;
  }

在 OpenCL 程序内核完成工作后,我们返回到主程序一侧工作。现在我们已经澄清了类功能,和必要的数据缓冲区的完整清单,我们可以为调用和维护上面讨论的内核,创建所有条件。

如上所述,我们基于神经层的 CNeuronBaseOCL 基类创建了一个新类 CNeuronEmbeddingOCL。神经层的主要功能继承自父类。我们必须往向类里添加新功能。

创建 a_Windows 动态数组,存储源数据映射。不过,我们不会创建一个单独的缓冲区对象来维护它。代之,我们创建一个变量 i_WindowsBuffer 来记录指向 OpenCL 关联环境中缓冲区的指针。在此,我们将创建变量来记录一个嵌入的大小,及所分析历史的深度 — 分别是 i_WindowOuti_StackSize

为嵌入权重比率和力矩矩阵创建数据缓冲区:

  • WeightsEmbedding;
  • FirstMomentumEmbed;
  • SecondMomentumEmbed.

但标准差缓冲区仅用于过渡计算。故此,我们不会在主程序的一侧创建它。我们仅在 OpenCL 关联环境内存中创建它,并在 i_STDBuffer 变量中存储指向它的指针。

这组被覆盖的方法极其标准,我们现在不会详述它们的目的。

class CNeuronEmbeddingOCL  :  public CNeuronBaseOCL
  {
protected:
   int               a_Windows[];
   int               i_WindowOut;
   int               i_StackSize;
   int               i_WindowsBuffer;
   int               i_STDBuffer;
   //---
   CBufferFloat      WeightsEmbedding;
   CBufferFloat      FirstMomentumEmbed;
   CBufferFloat      SecondMomentumEmbed;

   virtual bool      feedForward(CNeuronBaseOCL *NeuronOCL);               
   virtual bool      updateInputWeights(CNeuronBaseOCL *NeuronOCL); 

public:
                     CNeuronEmbeddingOCL(void);
                    ~CNeuronEmbeddingOCL(void);
   //---
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint stack_size, uint window_out, int &windows[]);
   //---
   virtual bool      calcInputGradients(CNeuronBaseOCL *NeuronOCL);          
   //---
   virtual bool      Save(int const file_handle);
   virtual bool      Load(int const file_handle);
   //---
   virtual int       Type(void)        const                      {  return defNeuronEmbeddingOCL;                  }
   virtual CLayerDescription* GetLayerInfo(void);
   virtual void      SetOpenCL(COpenCLMy *obj);
   virtual bool      Clear(void);
  };

在类构造函数中,初始化变量,以及指向含有初始值的缓冲区的指针。

CNeuronEmbeddingOCL::CNeuronEmbeddingOCL(void)
  {
   ArrayFree(a_Windows);
   if(!!OpenCL)
     {
      if(i_WindowsBuffer >= 0)
         OpenCL.BufferFree(i_WindowsBuffer);
      if(i_STDBuffer >= 0)
         OpenCL.BufferFree(i_STDBuffer);
     }
//--
   i_WindowsBuffer = INVALID_HANDLE;
   i_STDBuffer = INVALID_HANDLE;
   i_WindowOut = 0;
   i_StackSize = 1;
  }

嵌入层对象的直接初始化是在 Init 方法中运作的。除了常量之外,我们还在方法参数中传达了分析历史的深度(stack_size),嵌入向量大小(window_out),和“源数据映射”(windows[] 动态数组)。

bool CNeuronEmbeddingOCL::Init(uint numOutputs,uint myIndex,COpenCLMy *open_cl,uint stack_size, uint window_out,int &windows[])
  {
   if(CheckPointer(open_cl) == POINTER_INVALID || window_out <= 0 || windows.Size() <= 0 || stack_size <= 0)
      return false;
   if(!!OpenCL && OpenCL != open_cl)
      delete OpenCL;
   uint numNeurons = window_out * windows.Size() * stack_size;
   if(!CNeuronBaseOCL::Init(numOutputs,myIndex,open_cl,numNeurons,ADAM,1))
      return false;

我们在方法主体中创建一个源数据控制模块。然后,我们重新计算结果缓冲区的大小,即嵌入向量长度乘以模态数量、和所分析历史深度的乘积。注意,外部参数中没有模态总数。但我们得到了“初始数据映射”。生成的数组大小将告诉我们正在分析的模态数量。

结果缓冲区以及其它继承对象的直接初始化是在父类的类似方法中运作的,我们在完成准备操作后调用该方法。

初始化继承对象成功后,我们需要准备添加的实体。首先,我们初始化嵌入权重缓冲区。如上所述,该缓冲区是一个矩阵,其行数等于原始数据的体量,列数等于一个嵌入的向量大小。我们知道了嵌入大小。但要判定源数据的大小,我们需要将“数据映射”的所有值累加求和。在每个模态的结果总和中加上一行贝叶斯偏差。这样我们就可以得到嵌入权重缓冲区的大小。现在,我们将用随机值填充它,并将其传输到 OpenCL 关联环境内存。

   uint weights = 0;
   ArrayCopy(a_Windows,windows);
   i_WindowOut = (int)window_out;
   i_StackSize = (int)stack_size;
   for(uint i = 0; i < windows.Size(); i++)
      weights += (windows[i] + 1) * window_out;
   if(!WeightsEmbedding.Reserve(weights))
      return false;
   float k = 1.0f / sqrt((float)weights / (float)window_out);
   for(uint i = 0; i < weights; i++)
      if(!WeightsEmbedding.Add(k * (2 * GenerateWeight() - 1.0f)*WeightsMultiplier))
         return false;
   if(!WeightsEmbedding.BufferCreate(OpenCL))
      return false;

第一个和第二个矩阵缓冲器的大小相似。但我们用零值初始化它们,并将它们传输到 OpenCL 关联环境内存。

   if(!FirstMomentumEmbed.BufferInit(weights, 0))
      return false;
   if(!FirstMomentumEmbed.BufferCreate(OpenCL))
      return false;
//---
   if(!SecondMomentumEmbed.BufferInit(weights, 0))
      return false;
   if(!SecondMomentumEmbed.BufferCreate(OpenCL))
      return false;

接下来,我们创建原始数据和标准差映射缓冲区。

   i_WindowsBuffer = OpenCL.AddBuffer(sizeof(int) * a_Windows.Size(),CL_MEM_READ_WRITE);
   if(i_WindowsBuffer < 0 || !OpenCL.BufferWrite(i_WindowsBuffer,a_Windows,0,0,a_Windows.Size()))
      return false;
   i_STDBuffer = OpenCL.AddBuffer(sizeof(float) * a_Windows.Size(),CL_MEM_READ_WRITE);
   if(i_STDBuffer<0)
     return false;
//---
   return true;
  }

我们确保控制每个步骤的操作执行过程。该方法的所有操作完成后,将该方法的逻辑结果返回给调用程序。

初始化对象之后,我们必须为其主要功能创建方法。在我们的例子中,这些就是向前和向后验算方法。您也许已经猜到了,我们已经完成了在 OpenCL 程序中安排功能的主要工作。现在我们所要做的就是规划相应内核的调用。在开始之前,我们需要声明配合内核工作的常量:程序中的内核 ID,及其参数。如常,我们使用 #define 指令执行此功能。

#define def_k_Embedding                59
#define def_k_emb_inputs               0
#define def_k_emb_outputs              1
#define def_k_emb_weights              2
#define def_k_emb_windows              3
#define def_k_emb_std                  4
#define def_k_emb_stack_size           5
//---
#define def_k_EmbeddingHiddenGradient  60
#define def_k_ehg_inputs_gradient      0
#define def_k_ehg_outputs_gradient     1
#define def_k_ehg_weights              2
#define def_k_ehg_windows              3
#define def_k_ehg_std                  4
#define def_k_ehg_window_out           5
//---
#define def_k_EmbeddingUpdateWeightsAdam  61
#define def_k_euw_weights              0
#define def_k_euw_gradient             1
#define def_k_euw_inputs               2
#define def_k_euw_matrix_m             3
#define def_k_euw_matrix_v             4
#define def_k_euw_windows              5
#define def_k_euw_std                  6
#define def_k_euw_window_out           7
#define def_k_euw_learning_rate        8
#define def_k_euw_b1                   9
#define def_k_euw_b2                   10

我们将参考 feedForward 直接验算方法的示例,来安排将内核放入执行队列的过程。在方法参数中,与之前研究的所有类似参数一样,我们接收指向前一个神经层对象的指针。

bool CNeuronEmbeddingOCL::feedForward(CNeuronBaseOCL *NeuronOCL)
  {
   if(!NeuronOCL || !OpenCL)
      return false;

在方法主体中,我们检查接收到的指针,及配合 OpenCL 关联环境工作的对象指针。

接下来,我们将指向数据缓冲区的指针,和先前在内核参数中指定的必要常量传递给内核。不要忘记监控每一步的操作。

   if(!OpenCL.SetArgumentBuffer(def_k_Embedding, def_k_emb_inputs, NeuronOCL.getOutputIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__,GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_Embedding, def_k_emb_outputs, getOutputIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__,GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_Embedding, def_k_emb_std, i_STDBuffer))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__,GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_Embedding, def_k_emb_weights, WeightsEmbedding.GetIndex()))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__,GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgumentBuffer(def_k_Embedding, def_k_emb_windows, i_WindowsBuffer))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__,GetLastError(), __LINE__);
      return false;
     }
   if(!OpenCL.SetArgument(def_k_Embedding, def_k_emb_stack_size, i_StackSize))
     {
      printf("Error of set parameter kernel %s: %d; line %d", __FUNCTION__,GetLastError(), __LINE__);
      return false;
     }

所有参数成功传递后,我们需要为内核定义任务空间。正如我们上面所讨论的,内核将在二维任务空间中运行。在第一个维度中,我们将指示一个嵌入大小,而在第二个维度中,我们将指定欲分析的模态数量。

   uint global_work_offset[2] = {0,0};
   uint global_work_size[2]   = {i_WindowOut,a_Windows.Size()};

嵌入内核的一个特征是在一种模态的嵌入向量内对数据进行常规化。为了构建这个子进程,我们借助本地数组规划同一工作组内线程之间的数据交换。现在我们需要指定局部组的大小,它等于嵌入向量的大小。细微差别在于,在指定二维空间时,我们需要指定一个二维局部组。因此,局部组的第二个维度是 1。

   uint local_work_size[2]    = {i_WindowOut,1};

最后,调用内核排队方法,并控制执行操作的过程。

   if(!OpenCL.Execute(def_k_Embedding, 2, global_work_offset, global_work_size,local_work_size))
     {
      printf("Error of execution kernel %s: %d", __FUNCTION__,GetLastError());
      return false;
     }
//---
   return true;
  }

调用后向验算内核的过程与此类似,我们现在不再赘述这些方法。您可以在附件中找到所有必要的代码。我想专注以下几点。决策变换器是一个自回归模型,输入数据的一致性非常重要。上面,我们判定在每个时间步骤中,我们只向模型输入司喂新数据。分析历史记录的整个深度是复制以前的模型操作。本质上,我们使用 CNeuronEmbeddingOCL 层的结果缓冲区作为嵌入堆栈。这种方法可以降低主数据处理的成本。不过,它涉及一个需求,即在训练过程和操作期间提供一致的初始数据。同时,在训练期间我们经常使用源数据的随机样本。以前已经不止一次讨论过这样做的必要性。为了排除由于原始数据中的“临时跳转”,或在切换到替代轨迹时导致的数据损坏,我们需要一种清除嵌入堆栈的方法。Clear 方法正是为此目的而创建的。它的算法非常简单:我们只需用零值填充整个缓冲区,然后将数据复制到 OpenCL 关联环境内存中。

bool CNeuronEmbeddingOCL::Clear(void)
  {
   if(!Output.BufferInit(Output.Total(),0))
      return false;
   if(!OpenCL)
      return true;
//---
   return Output.BufferWrite();
  }

针对 CNeuronEmbeddingOCL 类方法算法的讨论到此结束。您可以在附件中找到其完整代码和所有方法。

作为完成工作的结果,我们在 CNeuronEmbeddingOCL 层的输出端具有几种不同模态的可比嵌入。这允许我们使用先前创建的变换器对象来实现所提出的决策变换器方法。这意味着我们能够转到模型架构描述的工作。在这种情况下,我们仅会用到一个模型 — 智能体。在我们的系列文章中,自发生这种情况以来已经有一段时间了。

但首先,我必须提醒您“源映射”。为了描述它,我们使用了一个以前在神经层描述类中不曾有的数组。我们来添加它。

class CLayerDescription    :  public CObject
  {
public:
   /** Constructor */
                     CLayerDescription(void);
   /** Destructor */~CLayerDescription(void) {};
   //---
   int               type;          ///< Type of neurons in layer (\ref ObjectTypes)
   int               count;         ///< Number of neurons
   int               window;        ///< Size of input window
   int               window_out;    ///< Size of output window
   int               step;          ///< Step size
   int               layers;        ///< Layers count
   int               batch;         ///< Batch Size
   ENUM_ACTIVATION   activation;    ///< Type of activation function (#ENUM_ACTIVATION)
   ENUM_OPTIMIZATION optimization;  ///< Type of optimization method (#ENUM_OPTIMIZATION)
   float             probability;   ///< Probability of neurons shutdown, only Dropout used
   int               windows[];
   //---
   virtual bool      Copy(CLayerDescription *source);
   //---
   virtual bool      operator= (CLayerDescription *source)  { return Copy(source); }
  };

我们在 CreateDescriptions 方法中描述模型架构。在参数中,该方法仅接收一个指向定义扮演者架构的动态数组的指针。我们将模型的神经层的定义保存到结果数组当中。

bool CreateDescriptions(CArrayObj *agent)
  {
//---
   CLayerDescription *descr;
//---
   if(!agent)
     {
      agent = new CArrayObj();
      if(!agent)
         return false;
     }
//--- Agent
   agent.Clear();

作为第一层,我们将指示源数据的完全连接神经层,我们按顺序将分析所需的所有数据写入其中。请注意,我们不会基于内容将源数据划分到单独的缓冲区。在这种情况下,它们的划分是相当武断的。我们只要按顺序写入。它们的逻辑分离将根据我们稍后创建的“源数据映射”在嵌入级别进行。

注意,源数据层仅包含有关系统最后状态的信息(奖励、环境状态、账户状态、时间戳、和上次智能体动作)。

//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   int prev_count = descr.count = (NRewards + BarDescr*NBarInPattern + AccountDescr + TimeDescription + NActions);
   descr.activation = None;
   descr.optimization = ADAM;
   if(!agent.Add(descr))
     {
      delete descr;
      return false;
     }

在源数据层之后,我们将指示批量常规化层,在其中进行数据预处理。再一次,我们不考虑所获得数据的不同性质。毕竟,该层针对每个属性在历史数据关联环境中独立运作常规化。

//--- layer 1
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBatchNormOCL;
   descr.count = prev_count;
   descr.batch = 1000;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!agent.Add(descr))
     {
      delete descr;
      return false;
     }

接下来是批量常规化层。在此,我们指明分析历史的深度、一个嵌入向量大小、和“源数据映射”。

//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronEmbeddingOCL;
   prev_count = descr.count = HistoryBars;
     {
      int temp[] = {BarDescr*NBarInPattern,AccountDescr,TimeDescription,NRewards,NActions};
      ArrayCopy(descr.windows,temp);
     }
   int prev_wout = descr.window_out = EmbeddingSize;
   if(!agent.Add(descr))
     {
      delete descr;
      return false;
     }

在嵌入层后面,我们将放置一个稀疏关注力模块 defNeuronMLMHSparseAttentionOCL,它将形成我们变换器的基础。方法作者用到一个原始转换器。不过,使用稀疏关注力模块将令我们明显提升所分析历史记录的深度,同时略微增加资源成本,及模型运行时间。

//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronMLMHSparseAttentionOCL;
   descr.count = prev_count;
   descr.window = prev_wout;
   descr.step = 4;
   descr.window_out = 16;
   descr.layers = 4;
   descr.probability = Sparse;
   descr.optimization = ADAM;
   if(!agent.Add(descr))
     {
      delete descr;
      return false;
     }

该模型由全连接层的决策模块,和输出处的变分自动编码器的潜在层完成,从而在扮演者政策中创建随机性。

//--- layer 4
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = LatentCount;
   descr.optimization = ADAM;
   descr.activation = LReLU;
   if(!agent.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 5
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   prev_count = descr.count = LatentCount;
   descr.activation = TANH;
   descr.optimization = ADAM;
   if(!agent.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 6
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = LatentCount;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!agent.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 7
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = 2 * NActions;
   descr.activation = SIGMOID;
   descr.optimization = ADAM;
   if(!agent.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 8
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronVAEOCL;
   descr.count = NActions;
   descr.optimization = ADAM;
   if(!agent.Add(descr))
     {
      delete descr;
      return false;
     }
//---
   return true;
  }

必须说,决策制定模块也与作者的 DT 算法中所采用的不同。方法作者在转换器的输出端使用了序列中最后一个令牌的解码器。我们分析整个序列,以便做出明智的决定。

在指定了模型架构之后,我们继续创建一款 EA,是为了与环境交互,并收集模型训练数据放入经验回放缓冲区 “\DT\Research.mq5” 之中。EA 结构与早前讨论过的完全相同,但值得关注的是 OnTick 跳价处理方法。正是在此处,初始数据序列根据上述映射形成。

在方法主体中,我们检查是否发生了新柱线开盘事件,并在必要时加载历史数据。不过,现在我们没有加载欲分析历史的整个深度,而只是更新了一个时间步骤的形态大小。这可以是来自最后一根、或许更多根蜡烛的数据。我们引入了 NBarInPattern 常量来调节数据加载的深度。请不要将其与 HistoryBars 常量混淆,我们将用它来确定嵌入堆栈的深度。

void OnTick()
  {
//---
   if(!IsNewBar())
      return;
//---
   int bars = CopyRates(Symb.Name(), TimeFrame, iTime(Symb.Name(), TimeFrame, 1), NBarInPattern, Rates);
   if(!ArraySetAsSeries(Rates, true))
      return;
//---
   RSI.Refresh();
   CCI.Refresh();
   ATR.Refresh();
   MACD.Refresh();
   Symb.Refresh();
   Symb.RefreshRates();

然后,我们依据历史数据创建一个数组,以便存储在轨迹之中,并将其传输到源数据缓冲区。该过程与前面讨论过的 EA 完全雷同。

//--- History data
   float atr = 0;
   for(int b = 0; b < (int)NBarInPattern; 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);

下一步是创建帐户状态的描述。数据收集则由先前应用的过程运作。不过,数据不会传输到单独的缓冲区,而是传输到 bState 单个原始数据缓冲区。

//--- Account description
   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;
//---
   bState.Add((float)((sState.account[0] - PrevBalance) / PrevBalance));
   bState.Add((float)(sState.account[1] / PrevBalance));
   bState.Add((float)((sState.account[1] - PrevEquity) / PrevEquity));
   bState.Add(sState.account[2]);
   bState.Add(sState.account[3]);
   bState.Add((float)(sState.account[4] / PrevBalance));
   bState.Add((float)(sState.account[5] / PrevBalance));
   bState.Add((float)(sState.account[6] / PrevBalance));

将时间戳加到相同的缓冲区。

//--- Time label
   double x = (double)Rates[0].time / (double)(D'2024.01.01' - D'2023.01.01');
   bState.Add((float)MathSin(2.0 * M_PI * x));
   x = (double)Rates[0].time / (double)PeriodSeconds(PERIOD_MN1);
   bState.Add((float)MathCos(2.0 * M_PI * x));
   x = (double)Rates[0].time / (double)PeriodSeconds(PERIOD_W1);
   bState.Add((float)MathSin(2.0 * M_PI * x));
   x = (double)Rates[0].time / (double)PeriodSeconds(PERIOD_D1);
   bState.Add((float)MathSin(2.0 * M_PI * x));

以下数据已根据决策变换器方法的要求生成。此处,我们将“在途回报”模态加到源数据缓冲区。这里也许有一个所需奖励的元素,或分解奖励的向量。我们将指明 3 个元素:余额变化、净值变化、和回撤。所有 3 个指标均以相对值表示。

//--- Return to go
   bState.Add(float(1-(sState.account[0] - PrevBalance) / PrevBalance));
   bState.Add(float(0.1f-(sState.account[1] - PrevEquity) / PrevEquity));
   bState.Add(0);

为了完成初始数据的向量,我们添加了智能体最后动作的向量。首次调用时,此向量均填充零值。

//--- Prev action
   bState.AddArray(AgentResult);

源数据向量已准备就绪,我们执行智能体的直接验算。

   if(!Agent.feedForward(GetPointer(bState), 1, false, (CBufferFloat*)NULL))
      return;

解释模型结果和进行交易的深层算法已转移,没有更改,我们也不会详述它。您可以在附件中找到 EA,及其所有方法的完整代码。我们转到 “\DT\Study.mq5” EA 中构建模型训练过程。EA 也从以前的作品中继承了很多。现在我们仅详细讨论训练模型的 Train 方法。

在方法主体中,我们首先检测存储在局部经验回放缓冲区的轨迹数量。

void Train(void)
  {
   int total_tr = ArraySize(Buffer);
   uint ticks = GetTickCount();

然后,我们基于训练迭代的次数安排一个循环,在其中,我们随机选择一条轨迹,以及该轨迹上的一个单独状态。此处的一切都和以前一样。

   bool StopFlag = false;
   for(int iter = 0; (iter < Iterations && !IsStopped() && !StopFlag); iter ++)
     {
      int tr = (int)((MathRand() / 32767.0) * (total_tr - 1));
      int i = (int)((MathRand() * MathRand() / MathPow(32767, 2)) * MathMax(Buffer[tr].Total - 2 * HistoryBars,MathMin(Buffer[tr].Total,20)));
      if(i < 0)
        {
         iter--;
         continue;
        }

此处就是差异的开始。记住,我们谈到了向模型输入提供顺序数据的必要性。但我们是轨迹上的随机状态。为了消除分析序列中的数据损坏,我们清除了嵌入缓冲区和智能体最后动作向量。

      Actions = vector<float>::Zeros(NActions);
      Agent.Clear();

然后我们组织一个嵌套循环,其迭代次数是分析历史深度的 3 倍,当然,如果所保存轨迹的大小允许这样做的话。在这个嵌套循环的主体中,我们将严格按照与环境交互的顺序,司喂来自保存的轨迹输入数据来训练模型。首先,我们将历史价格走势数据加载到缓冲区之中。

      for(int state = i; state < MathMin(Buffer[tr].Total - 1,i + HistoryBars * 3); state++)
        {
         //--- History data
         State.AssignArray(Buffer[tr].States[state].state);

以下是有关帐户状态的信息。

         //--- Account description
         float PrevBalance = (state == 0 ? Buffer[tr].States[state].account[0] : Buffer[tr].States[state - 1].account[0]);
         float PrevEquity = (state == 0 ? Buffer[tr].States[state].account[1] : Buffer[tr].States[state - 1].account[1]);
         State.Add((Buffer[tr].States[state].account[0] - PrevBalance) / PrevBalance);
         State.Add(Buffer[tr].States[state].account[1] / PrevBalance);
         State.Add((Buffer[tr].States[state].account[1] - PrevEquity) / PrevEquity);
         State.Add(Buffer[tr].States[state].account[2]);
         State.Add(Buffer[tr].States[state].account[3]);
         State.Add(Buffer[tr].States[state].account[4] / PrevBalance);
         State.Add(Buffer[tr].States[state].account[5] / PrevBalance);
         State.Add(Buffer[tr].States[state].account[6] / PrevBalance);

还有一个时间戳。

         //--- Time label
         double x = (double)Buffer[tr].States[state].account[7] / (double)(D'2024.01.01' - D'2023.01.01');
         State.Add((float)MathSin(2.0 * M_PI * x));
         x = (double)Buffer[tr].States[state].account[7] / (double)PeriodSeconds(PERIOD_MN1);
         State.Add((float)MathCos(2.0 * M_PI * x));
         x = (double)Buffer[tr].States[state].account[7] / (double)PeriodSeconds(PERIOD_W1);
         State.Add((float)MathSin(2.0 * M_PI * x));
         x = (double)Buffer[tr].States[state].account[7] / (double)PeriodSeconds(PERIOD_D1);
         State.Add((float)MathSin(2.0 * M_PI * x));

在这个阶段,我们将实际累积的奖励传输到“在途回报”中轨迹的末尾。该方式与环境交互 EA 中的类似令牌略有不同。但这样就是允许我们训练模型的原因。

         //--- Return to go
         State.AddArray(Buffer[tr].States[state].rewards);

加入来自经验回放缓冲区的智能体上一个时间步骤的动作。

         //--- Prev action
         State.AddArray(Actions);

一个训练迭代的源数据缓冲区已准备就绪,我们调用智能体的前向验算方法。

         //--- Feed Forward
         if(!Agent.feedForward(GetPointer(State), 1, false, (CBufferFloat*)NULL))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            StopFlag = true;
            break;
           }

正向验算成功完成后,我们必须执行反向验算,并调整模型参数。于此浮现出目标值的问题,其能以一种非常简单的方式解决。我们取智能体与环境交互时实际执行的动作当成目标值。矛盾的是,这是一种纯粹的监督训练。但是强化学习在哪里?奖励优化在哪里?我们甚至不能使用监督学习,因为与环境交互时采取的动作并非最优的。

我们训练了一个自回归模型,其基于对行进轨迹和所需结果的了解,生成最优动作。在这方面,主要角色是由“在途回报”令牌的实际累积奖励来扮演。毕竟,没有人怀疑是实际执行的动作导致了实际的奖励。因此,我们可以很容易地训练模型,以便识别这些行动与收获的奖励。经过良好训练的模型随后将能够生成动作,以便在操作期间获得期待的结果。

决策变换器的作者建议将 MSE 用于连续动作空间。我们将用 CAGrad 方法对其进行补充。

         //--- Policy study
         Actions.Assign(Buffer[tr].States[state].action);
         vector<float> result;
         Agent.getResults(result);
         Result.AssignArray(CAGrad(Actions - result) + result);
         if(!Agent.backProp(Result, (CBufferFloat*)NULL))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            StopFlag = true;
            break;
           }

在反向验算成功后,我们会通知用户训练状态,并转到学习过程循环系统的下一次迭代。所有迭代完成后,我们将启动终止 EA 工作的过程。

   Comment("");
//---
   PrintFormat("%s -> %d -> %-15s %10.7f", __FUNCTION__, __LINE__, "Agent", Agent.getRecentAverageError());
   ExpertRemove();
//---
  }

在附件中找到文章中使用的所有程序的完整代码。


3. 测试

我们已经做了大量的工作,利用 MQL5 实现决策变换器方法。现在到了训练和测试模型的时候了。如常,模型的训练和测试是依据 EURUSD H1 进行的。采用所有指标的默认参数。训练周期为 2023 年的 7 个月。我们将取用 2023 年 8 月的历史数据测试该模型。

基于这种方法的测试结果,我们可以说这个思路非常有趣。但在随机市场中,我设法达成了期待的结果。虽然仍然有可能在训练样本上达成可接受的结果,但我们看到在测试周期的前十天,新数据的余额有所增加。但随之而来的是一连串亏损交易。结果就是,该模型在测试数据上产生了亏损。尽管我们看到平均赢利交易比平均亏损略多 1.0%,但这还不够。盈利交易的占比仅为 47.76%。底线是 0.92 的盈利因子。

DT 测试图表 DT 测试结果

结束语

在这篇文章中,我讲述了一种相当有趣的方法,叫做决策变换器,这是一种创新的强化学习方法。与传统方法不同,决策变换器在所需奖励的自回归模型的上下文中对动作序列进行建模。这令智能体能够学会基于未来目标做出决策,并根据这些目标优化其行为。

在本文的实践部分,我们利用 MQL5 实现了所提出的方法,并对模型进行了训练和测试。然而,经过训练的模型在整个测试期间都未能产生利润。在测试样本的前半部分,模型做到盈利,但随着测试的继续,所有盈利都损失殆尽。该算法尚有潜力。不过,需要对模型进行额外的工作才能获得期待的结果。


链接


文中所用程序

# 名称 类型 说明
1 Research.mq5 智能交易系统 样本收集 EA
2 Study.mq5  智能交易系统 智能体训练 EA
3 Test.mq5 智能交易系统 模型测试 EA
4 Trajectory.mqh 类库 系统状态定义结构
5 NeuroNet.mqh 类库 创建神经网络的类库
6 NeuroNet.cl 代码库 OpenCL 程序代码库



本文由MetaQuotes Ltd译自俄文
原文地址: https://www.mql5.com/ru/articles/13347

附加的文件 |
MQL5.zip (525.51 KB)
为 Metatrader 5 开发MQTT客户端:TDD方法——第4部分 为 Metatrader 5 开发MQTT客户端:TDD方法——第4部分
本文是一系列文章的第四部分,介绍了我们为 MQTT 协议开发本机 MQL5 客户端的步骤。在这一部分中,我们将描述什么是 MQTT v5.0 属性,它们的语义,以及我们如何阅读其中的一些属性,并提供一个如何使用属性来扩展协议的简短示例。
机器学习中的量化(第1部分):使用 CatBoost 的理论、示例代码和实现分析 机器学习中的量化(第1部分):使用 CatBoost 的理论、示例代码和实现分析
本文探讨了量化在树模型构建中的理论应用,并展示了使用 CatBoost 实现的量化方法。不使用复杂的数学方程。
群体优化算法:混合蛙跳算法(SFL) 群体优化算法:混合蛙跳算法(SFL)
本文详细描述了混合蛙跳(Shuffled Frog-Leaping,SFL)算法及其在求解优化问题中的能力。SFL算法的灵感来源于青蛙在自然环境中的行为,为函数优化提供了一种新的方法。SFL算法是一种高效灵活的工具,能够处理各种数据类型并实现最佳解决方案。
如何利用 MQL5 创建简单的多币种智能交易系统(第 2 部分):指标信号:多时间帧抛物线 SAR 指标 如何利用 MQL5 创建简单的多币种智能交易系统(第 2 部分):指标信号:多时间帧抛物线 SAR 指标
本文中的多币种智能交易系统是智能交易系统或交易机器人,它仅在一个品种图表上就能交易(开单、平单、和管理订单,例如:尾随停损和止盈)超过 1 个交易品种对。这次我们只用 1 个指标,即抛物线 SAR 或 iSAR, 将其应用在 PERIOD_M15 到 PERIOD_D1 的多个时间帧。