English Русский Español Deutsch 日本語 Português
preview
神经网络变得简单(第 72 部分):噪声环境下预测轨迹

神经网络变得简单(第 72 部分):噪声环境下预测轨迹

MetaTrader 5交易系统 | 7 十月 2024, 10:35
43 0
Dmitriy Gizlyk
Dmitriy Gizlyk

概述

在金融市场交易的背景下,通过分析资产的历史轨迹来预测资产的未来走势极其重要,因为分析过去趋势可能是成功策略的关键因素。由于潜在因素的变化,以及市场对它们的反应,未来的资产轨迹往往包含不确定性,这决定了资产许多未来的潜在走势。因此,预测市场走势的有效方法必须能够生成潜在未来轨迹的分布,或者至少几种合理的场景。

尽管针对最可能的预测,存在相当多的现有架构,但在预测金融资产的未来轨迹时,模型可能会面临预测过于简陋的问题。问题仍然存在,因为模型只是狭义地解读训练集中的数据。在缺乏清晰的资产轨迹形态的情况下,预测模型最终会生成简陋或同质的走势场景,其并不能捕捉金融产品走势的多样性变化。这就可能会导致预测准确性降低。

论文《遵循自监督路标噪声预测的增强轨迹预测》的作者提供了一种解决这些问题的新方式,自监督路标噪声预测(SSWNP),它由两个模块组成:

  • 空间一致性模块
  • 噪声预测模块

首先创建两个不同的历史观测轨迹视图:关键点空间域的干净视图和加噪视图。顾名思义,干净的版本代表原始轨迹,而加噪版本代表过去轨迹添加了噪声之后在原始特征空间的移动。这种方式利用了这样一个事实,即过去轨迹的噪声版本与来自训练集数据的狭义解释不对应。该模型利用这些额外信息来克服预测过于简陋的问题,并探索更多样化的场景。在生成两个不同的过去轨迹之后,我们训练一个未来轨迹预测模型,以便保持两者预测之间的空间一致性,并学习走势预测任务之外的时空特征。

噪声预测模块解决了识别所分析轨迹中噪声的辅助问题。这有助于走势预测模型更好地对潜在的空间多样性进行建模,并提高对走势预测中基本表示的理解,从而改进未来的预测。

该方法的作者进行了额外的实验,以实证演示空间一致性和噪声预测模块对 SSWNP 至关重要。当仅按空间一致性模块来解决走势预测问题时,会观察到训练模型的性能欠佳。因此,他们把这两个模块都集成到他们的工作当中。


1. SSWNP 算法

轨迹预测的目标是基于之前观察到的轨迹判定个体在动态环境中最可能的未来轨迹。轨迹由空间点的时间序列表示,称为路标。观测轨迹涵盖从 t1 至 tob 的区间,可以表示为

其中 Xi* 对应于时间步 t*i 的坐标。类似地,个体 i 在 [tob+1,tfu] 区间的预测未来轨迹可以描述为 Ŷtob+1≤t≤tfu。个体 i 未来走势的相应真实轨迹可以描述为 Ytob+1≤t≤tfu

在 SSWNP 方法中,首先创建两个不同的轨迹视图:一个是干净视图 (X≤tob),另一个是加噪视图 (Ẍ≤tob)。干净视图对应于训练数据集中的原始轨迹,而加噪视图对应于添加噪声之后在特征空间中移动的轨迹。

来自标准正态分布 N(0, 1) 的噪声用于扭曲干净的轨迹。该方法的作者引入了一个称为噪声因子 (ω) 的参数,由其控制路标的空间走势。

在创建干净和加噪轨迹视图之后,我们将它们馈送到特征提取模型 (Θfe),由其生成对应于干净视图和加噪视图的特征。然后将生成的特征馈送到轨迹预测模型 (Θsup),以便预测轨迹 Ŷtob+1≤t≤tfuŸtob+1≤t≤tfu,如以下公式所示:

我们训练模型从而将预测轨迹与训练数据集中真实轨迹之间的间隙最小化。如是所见,通过最小化来自干净和加噪初始数据 (ŶŸ) 到训练数据集 (Y) 真实轨迹的预测误差,我们间接地缩小了 2 个预测轨迹之间的间隙。这维护了基于干净观测轨迹的未来轨迹预测与加噪轨迹之间的空间一致性。

此外,SSWNP 方法解决了自监督噪声预测问题,其中包括预测以干净形式存在的噪声,即观察到的过去轨迹 X≤tob,以及来自 Ẍ≤tob 的加噪形式。此处的目标是估算与给定观测路标所关联的噪声值。

注意,由模型 Θfe 提取的特征当作噪声预测模型 (Θss) 的输入数据,该模型判定所观察轨迹中的噪声水平(干净和加噪视图)。对于噪声预测模型的自我监督学习的损失函数,该方法的作者建议使用均方根误差(MSE)。

此处 0 值表示来自轨迹的干净版并未加噪。

SSWNP 方法的常用损失函数表示为:

其中 λ 表示在使用所提出的方法训练模型时噪声预测误差相对于总误差的贡献。

“自监督路标噪声预测” 方法的原始可视化如下所示。


2. 利用 MQL5 实现

我们已经看到了“自监督路标噪声预测”方法的理论方面。如您所见,所提议方法对所用模型的架构或源数据的结构没有任何限制。这令我们能够将所提议方法与我们之前研究的大量算法集成。特别是,在本文中,我们将把所提议方法添加到自动编码器训练算法 TrajNet 之中,我们在最近的《目标条件预测编码》文章中讨论了这种方法。

正如我们之前所讨论的,GCPC 算法提供了 2 个阶段的模型训练:

本文讨论的 SSWNP 方法旨在提高预测未来轨迹的效率。因此,它只涵盖了 “轨迹函数训练” 阶段。我们将对这个阶段进行必要的调整。第二阶段,“行为政策训练”将以其现有形式运用。

2.1方法集成问题

在将新方法集成到现有结构当中之时,我们必须确保我们所做的更改不会破坏已构建的流程。因此,在开始我们的工作之前,我们必须分析新方式对以前创建的学习过程和模型的后续操作的影响。

在训练数据集中的轨迹里加噪显然会改变原始数据的分布。由此,这将影响批处理常规化层的参数,在其中我们对源数据进行预处理。一方面,这就是我们正在努力达成的目标。我们希望训练一个模型,能在高随机性环境中更接近真实条件下工作。另一方面,添加随机噪声可能会令原始数据超出分析参数的实际值。为了最大限度地减少该因素的负面影响,该算法的作者添加了一个噪声因子(ω),用于调节数据偏移量。在我们拥有 “原始” 非常规化数据的条件下,我们需要为源数据的每个衡量度提供单独的噪声因子。因此,我们转而使用一个噪声因子向量。然后,选择超参数向量就变成了一项相当复杂的任务,其复杂性随着分析参数数量的增加而增加。

这个问题的解决方案,如其所证,十分直截了当。将正态分布中的噪声乘以某个因子,实际上与我们在变分自动编码器层中所用的重新参数化技巧非常相似。

重新参数化技巧

因此,通过使用训练数据集分布的参数,我们可以将模型保持在原始分布之内。同时,我们在所分析环境中添加了内在随机性。

然而,于此还必须考虑一点。我们将噪声添加到训练数据集的真实轨迹当中,而非用随机值替换它们的数据。当直接解决问题时,我们得到初始数据的分布参数。

我们再看一下使用噪声的思路。在特定时间点,我们获得了每个分析参数的实际数据。在下一个时间步,参数会发生一定量的变化。每个参数变化的大小取决于大量不同的因素,这令它接近于随机变量。同时,这种变化也有其局限性。因此,为了保护原始数据的自然分布,我们可以检测每个分析参数的 2 个后续值之间这种偏差的分布参数。当我们重新参数化噪声时。这些将作为参数。

此处,我们必须考虑到这样一个事实,即参数值的重大变化往往表明市场局势的变化。根据 SSWNP 方法,训练该模型是为了最小化来自干净数据和加噪数据的轨迹预测之间的间隙。因此,我们将运用该方法作者提出的噪声因子来限制训练集真实轨迹的偏差。

第二点是在 GCPC 方法中使用 DropOut 层,它也用作一种正则化,旨在训练模型忽略一些“异常值”,并恢复缺失的参数。在组合方法的情况下,我们可经由 DropOut 层忽略已添加到参数掩码中的噪声。另一方面,与加噪相比,参数掩码令模型解决问题变得更加困难。

如前所述,我们不应该违反之前构建的流程。因此,我们不会自编码器架构中将 DropOut 层排除。观察模型训练结果会很有趣。

现在我们看看“自监督路标噪声预测”方法的构造。根据算法,我们将训练 3 个模型:

  • 特征提取模型
  • 轨迹预测模型
  • 噪声预测模型

我们计划将 SSWNP 算法集成到之前构建的 GCPC 流程之中。我们尝试比较两种方法的模型。SSWNP 特征提取模型对应于 GCPC 编码器。反过来,GCPC 解码器可以表示为 SSWNP 轨迹预测模型。如此,我们需要添加一个噪声预测模型。

2.2模型架构

模型架构将在 CreateTrajNetDescriptions 方法中描述,我们将往该方法里添加第三个模型的描述。在参数中,该方法接收指向三个动态数组的指针,以便描述这三个模型的架构。在方法的主体中,我们检查所接收指针的相关性,并在必要时创建新的对象实例。

bool CreateTrajNetDescriptions(CArrayObj *encoder, CArrayObj *decoder, CArrayObj *noise)
  {
//---
   CLayerDescription *descr;
//---
   if(!encoder)
     {
      encoder = new CArrayObj();
      if(!encoder)
         return false;
     }
   if(!decoder)
     {
      decoder = new CArrayObj();
      if(!decoder)
         return false;
     }
   if(!noise)
     {
      noise = new CArrayObj();
      if(!noise)
         return false;
     }

我们复制了编码器和解码器架构的描述,没有变化。正如我们在之前的文章中所看到的,我们将原始初始数据输入到编码器当中,其中我们只显示历史价格变化,和所分析指标。

//--- Encoder
   encoder.Clear();
//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   int prev_count = descr.count = (HistoryBars * BarDescr);
   descr.activation = None;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

它们在批量常规化层中进行初级处理。

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

常规化数据在 DropOut 层中随机遮掩。

//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronDropoutOCL;
   descr.count = prev_count;
   descr.probability = 0.8f;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

之后,我们利用卷积层模块搜索稳定的形态。

//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConvOCL;
   prev_count = descr.count = prev_count - 2;
   descr.window = 3;
   descr.step = 1;
   int prev_wout = descr.window_out = 3;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 4
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronSoftMaxOCL;
   descr.count = prev_count;
   descr.step = prev_wout;
   descr.optimization = ADAM;
   descr.activation = None;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 5
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConvOCL;
   prev_count = descr.count = prev_count;
   descr.window = prev_wout;
   descr.step = prev_wout;
   prev_wout = descr.window_out = 8;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 6
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronSoftMaxOCL;
   descr.count = prev_count;
   descr.step = prev_wout;
   descr.optimization = ADAM;
   descr.activation = None;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

然后我们在全连接层块中处理数据。

//--- layer 7
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = LatentCount;
   descr.optimization = ADAM;
   descr.activation = LReLU;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 8
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   prev_count = descr.count = EmbeddingSize;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

我们反复添加编码器之前验算的结果。

//--- layer 9
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronConcatenate;
   descr.count = 2 * EmbeddingSize;
   descr.window = prev_count;
   descr.step = EmbeddingSize;
   descr.optimization = ADAM;
   descr.activation = None;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

然后我们将数据传输到我们正在分析历史的内部堆栈之中。

//--- layer 10
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronEmbeddingOCL;
   prev_count = descr.count = GPTBars;
     {
      int temp[] = {EmbeddingSize, EmbeddingSize};
      ArrayCopy(descr.windows, temp);
     }
   prev_wout = descr.window_out = EmbeddingSize;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

历史数据集的结果在关注度模块中分析。

//--- layer 11
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronMLMHAttentionOCL;
   descr.count = prev_count * 2;
   descr.window = prev_wout;
   descr.step = 4;
   descr.window_out = 16;
   descr.layers = 4;
   descr.optimization = ADAM;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

分析结果由一个全连接层压缩。

//--- layer 12
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   prev_count = descr.count = EmbeddingSize;
   descr.optimization = ADAM;
   descr.activation = None;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }
//--- layer 13
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronSoftMaxOCL;
   descr.count = prev_count;
   descr.step = 1;
   descr.optimization = ADAM;
   descr.activation = None;
   if(!encoder.Add(descr))
     {
      delete descr;
      return false;
     }

在编码器输出处,我们调用 SoftMax 函数对数据进行常规化。

编码器的前馈验算结果被馈送到解码器当中。

//--- Decoder
   decoder.Clear();
//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   descr.count = EmbeddingSize;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!decoder.Add(descr))
     {
      delete descr;
      return false;
     }

在这种情况下,我们正在处理从先前模型获得的数据,这些数据已经被常规化。因此,无需对数据进行预处理。我们立即使用全连接层扩展它们。

//--- layer 1
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   prev_count = descr.count = (HistoryBars + PrecoderBars) * EmbeddingSize;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!decoder.Add(descr))
     {
      delete descr;
      return false;
     }

接收到的数据在关注度模块中进行分析。

//--- layer 2
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronMLMHAttentionOCL;
   prev_count = descr.count = prev_count / EmbeddingSize;
   prev_wout = descr.window = EmbeddingSize;
   descr.step = 4;
   descr.window_out = 16;
   descr.layers = 2;
   descr.optimization = ADAM;
   if(!decoder.Add(descr))
     {
      delete descr;
      return false;
     }

在关注度模块的输出中,我们有一个针对每根所预测烛条的嵌入。为了解码生成的嵌入,我们将用到一个多模型全连接层。

//--- layer 3
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronMultiModels;
   descr.count = 3;
   descr.window = prev_wout;
   descr.step = prev_count;
   descr.activation = None;
   descr.optimization = ADAM;
   if(!decoder.Add(descr))
     {
      delete descr;
      return false;
     }

在描述了编码器和解码器的架构之后,我们需要添加对噪声预测模型架构的描述。该模型与 解码器一样,使用编码器的结果作为输入数据。因此,我们只复制原始的解码器数据层。 

//--- Noise Prediction
   noise.Clear();
//--- Input layer
   if(!(descr = new CLayerDescription()))
      return false;
   descr.Copy(decoder.At(0));
   if(!noise.Add(descr))
     {
      delete descr;
      return false;
     }

接下来,使用全连接层,我们将接收到的数据扩展到编码器输入端原始数据的大小。

//--- layer 1
   if(!(descr = new CLayerDescription()))
      return false;
   descr.type = defNeuronBaseOCL;
   prev_count = descr.count = HistoryBars * EmbeddingSize;
   descr.activation = LReLU;
   descr.optimization = ADAM;
   if(!noise.Add(descr))
     {
      delete descr;
      return false;
     }

现在要关注。在下一步中,对于整个系列文章可能是第一次,我根据所选的超参数为模型架构创建了分支这里的关键是编码器输入处所分析烛条的数量。当分析多根烛条时,模型架构将类似于解码器。我们使用关注度模块和多模型层来解码嵌入。只是在此处,我们谈论的不是预测烛条,而是所分析烛条。

//---
   if(HistoryBars > 1)
     {
      //--- layer 2
      if(!(descr = new CLayerDescription()))
         return false;
      descr.type = defNeuronMLMHAttentionOCL;
      prev_count = descr.count = prev_count / EmbeddingSize;
      prev_wout = descr.window = EmbeddingSize;
      descr.step = 4;
      descr.window_out = 16;
      descr.layers = 2;
      descr.optimization = ADAM;
      if(!noise.Add(descr))
        {
         delete descr;
         return false;
        }
      //--- layer 3
      if(!(descr = new CLayerDescription()))
         return false;
      descr.type = defNeuronMultiModels;
      descr.count = BarDescr;
      descr.window = prev_wout;
      descr.step = prev_count;
      descr.activation = None;
      descr.optimization = ADAM;
      if(!noise.Add(descr))
        {
         delete descr;
         return false;
        }
     }

若在编码器输入处仅分析一根烛条时,没有必要用到分析不同烛条之间关系的关注度层。因此,我们将用到一个简单的感知器。

   else
     {
      //--- layer 2
      if(!(descr = new CLayerDescription()))
         return false;
      descr.type = defNeuronBaseOCL;
      descr.count = LatentCount;
      descr.optimization = ADAM;
      descr.activation = LReLU;
      if(!noise.Add(descr))
        {
         delete descr;
         return false;
        }
      //--- layer 3
      if(!(descr = new CLayerDescription()))
         return false;
      descr.type = defNeuronBaseOCL;
      prev_count = descr.count = BarDescr;
      descr.activation = None;
      descr.optimization = ADAM;
      if(!noise.Add(descr))
        {
         delete descr;
         return false;
        }
     }
//---
   return true;
  }

以上描述仅提供给针对参与轨迹函数模型训练的模型架构。所用的个体行为政策训练模型的架构,无需更改。您可以在附件中找到它们。上一篇文章中给出了详细的讲述。

2.3模型训练程序

在描述了所用模型的架构之后,我们可以继续研究程序的算法。请注意,SSWNP 方法的作者并未对源数据的选择和训练观测轨迹的收集提出要求。因此,与环境交互的程序按原样使用,无需任何调整。附件中提供了本文中用到的所有程序的完整代码,您可据其研究它们。如果需要澄清,请参考上一篇文章,或在讨论区中提问。

我们转到轨迹函数训练 EA ...\Experts\SSWNP\StudyEncoder.mq5,其中我们将同时训练 3 个模型:

  • 特征提取模型(Encoder)
  • 轨迹预测模型(Decoder)
  • 噪声预测模型(Noise)。
CNet                 Encoder;
CNet                 Decoder;
CNet                 Noise;

如理论部分所述,要实现 SSWNP 算法,我们需要定义 2 个超参数。在我们的程序中,它们将以常量实现。

#define        STE_Noise_Multiplier    1.0f/10        // λ determined the impact of noise prediction error
#define        STD_Delta_Multiplier    1.0f/10        // noise factor ω

 在 EA 初始化方法中,我们首先加载训练集。

//+------------------------------------------------------------------+
//| Expert initialization function                                   |
//+------------------------------------------------------------------+
int OnInit()
  {
//---
   ResetLastError();
   if(!LoadTotalBase())
     {
      PrintFormat("Error of load study data: %d", GetLastError());
      return INIT_FAILED;
     }

然后我们尝试打开以前训练的模型。如果加载模型时出错,我们会创建新的模型,并用随机参数初始化它们。

//--- load models
   float temp;
   if(!Encoder.Load(FileName + "Enc.nnw", temp, temp, temp, dtStudied, true) ||
      !Decoder.Load(FileName + "Dec.nnw", temp, temp, temp, dtStudied, true) ||
      !Noise.Load(FileName + "NP.nnw", temp, temp, temp, dtStudied, true))
     {
      Print("Init new models");
      CArrayObj *encoder = new CArrayObj();
      CArrayObj *decoder = new CArrayObj();
      CArrayObj *noise = new CArrayObj();
      if(!CreateTrajNetDescriptions(encoder, decoder, noise))
        {
         delete encoder;
         delete decoder;
         delete noise;
         return INIT_FAILED;
        }
      if(!Encoder.Create(encoder) || !Decoder.Create(decoder) ||
         !Noise.Create(noise))
        {
         delete encoder;
         delete decoder;
         delete noise;
         return INIT_FAILED;
        }
      delete encoder;
      delete decoder;
      delete noise;
      //---
     }

将所有模型传输到单一 OpenCL 关联环境之中。

//---
   OpenCL = Encoder.GetOpenCL();
   Decoder.SetOpenCL(OpenCL);
   Noise.SetOpenCL(OpenCL);

然后,我们为所用模型架构的关键参数加上一层控制。

//---
   Encoder.getResults(Result);
   if(Result.Total() != EmbeddingSize)
     {
      PrintFormat("The scope of the Encoder does not match the embedding size count (%d <> %d)", EmbeddingSize, 
                                                                                                     Result.Total());
      return INIT_FAILED;
     }
//---
   Encoder.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;
     }
//---
   Decoder.GetLayerOutput(0, Result);
   if(Result.Total() != EmbeddingSize)
     {
      PrintFormat("Input size of Decoder doesn't match Encoder output (%d <> %d)", Result.Total(), EmbeddingSize);
      return INIT_FAILED;
     }
//---
   Noise.GetLayerOutput(0, Result);
   if(Result.Total() != EmbeddingSize)
     {
      PrintFormat("Input size of Noise Prediction model doesn't match Encoder output (%d <> %d)", Result.Total(), 
                                                                                                      EmbeddingSize);
      return INIT_FAILED;
     }
//---
   Noise.getResults(Result);
   if(Result.Total() != (HistoryBars * BarDescr))
     {
      PrintFormat("Output size of Noise Prediction model doesn't match state description (%d <> %d)", Result.Total(), 
                                                                                           (HistoryBars * BarDescr));
      return INIT_FAILED;
     }

成功通过所有控制之后,我们创建辅助数据缓冲区。

//---
   if(!LastEncoder.BufferInit(EmbeddingSize, 0) ||
      !Gradient.BufferInit(EmbeddingSize, 0) ||
      !LastEncoder.BufferCreate(OpenCL) ||
      !Gradient.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);
  }

在 EA 的逆初始化方法中,我们保存经过训练的模型,并清除先前创建的动态对象占用的内存。

//+------------------------------------------------------------------+
//| Expert deinitialization function                                 |
//+------------------------------------------------------------------+
void OnDeinit(const int reason)
  {
//---
   if(!(reason == REASON_INITFAILED || reason == REASON_RECOMPILE))
     {
      Encoder.Save(FileName + "Enc.nnw", 0, 0, 0, TimeCurrent(), true);
      Decoder.Save(FileName + "Dec.nnw", Decoder.getRecentAverageError(), 0, 0, TimeCurrent(), true);
      Noise.Save(FileName + "NP.nnw", Noise.getRecentAverageError(), 0, 0, TimeCurrent(), true);
     }
   delete Result;
   delete OpenCL;
  }

训练模型的实际过程在 Train 方法中实现。如前,在方法的主体中,我们首先计算了从经验回放缓冲区中选择轨迹的概率。

//+------------------------------------------------------------------+
//| Train function                                                   |
//+------------------------------------------------------------------+
void Train(void)
  {
//---
   vector<float> probability = GetProbTrajectories(Buffer, 0.9);

然后我们创建并初始化必要的局部变量。

//---
   vector<float> result, target, inp;
   matrix<float> targets;
   matrix<float> delta;
   STE = vector<float>::Zeros((HistoryBars + PrecoderBars) * 3);
   STE_Noise = vector<float>::Zeros(HistoryBars * BarDescr);
   int std_count = 0;
   int batch = GPTBars + 50;
   bool Stop = false;
   uint ticks = GetTickCount();

准备工作完成。接下来,我们创建一个模型训练循环系统。如您所知,编码器中所用的 GPT 架构对输入数据的顺序设定了严格的要求。因此,我们创建了一个嵌套循环系统。在外部循环的主体中,我们抽取一条轨迹,及其状态,以便启动批次训练。在嵌套循环中,我们按照来自一条轨迹的批次顺序状态训练模型。

此处会遇到另一个挑战。我们不能在干净和加噪数据里采用同一序列。根据 SSWNP 方法,噪声被添加到轨迹当中,而非单个状态。

同时,我们不能在一次迭代中交替地将干净状态和加噪状态交替馈送到模型里。在内部堆栈中,状态模型将被混合,并会将它们视为单条轨迹。这会极大地扭曲所分析序列。

一个可接受的解决方案是交替轨迹。该模型首先依据干净轨迹进行训练,然后依据加噪轨迹进行训练。这种方式令我们能够同时解决另一个与噪声重新参数化系数向量有关的问题。在干净数据上训练模型时,我们会收集有关参数变化分布的信息。我们用收集到的分布值,针对依据加噪数据训练模型时添加的噪声重新参数化。

如上所述,我们创建了一个外部循环,在其中对轨迹和初始状态进行采样。

   for(int iter = 0; (iter < Iterations && !IsStopped() && !Stop); iter ++)
     {
      int tr = SampleTrajectory(probability);
      int state = (int)((MathRand() * MathRand() / MathPow(32767, 2)) * (Buffer[tr].Total - 3 - PrecoderBars - batch));
      if(state < 0)
        {
         iter--;
         continue;
        }

然后我们清除模型堆栈和辅助缓冲区。

      Encoder.Clear();
      Decoder.Clear();
      Noise.Clear();
      LastEncoder.BufferInit(EmbeddingSize, 0);

我们判定训练封包在轨迹上的最终状态,并清除矩阵,以便收集有关所分析参数变化的信息。

      int end = MathMin(state + batch, Buffer[tr].Total - PrecoderBars);
      delta = matrix<float>::Zeros(end - state - 1, Buffer[tr].States[state].state.Size());

注意,方差矩阵的大小比训练批次小 1 行。这是因为在这个矩阵中,我们将保存 2 个后续状态之间变化的增量。

在这个阶段,一切就绪,可以开始依据干净轨道训练模型了。如此,我们创建了第一个嵌套训练循环。

      for(int i = state; i < end; i++)
        {
         inp.Assign(Buffer[tr].States[i].state);
         State.AssignArray(inp);
         int row = i - state;
         if(i < (end - 1))
            delta.Row(inp, row);

在循环的主体中,我们从训练样本中提取已分析状态,并将其传输到源数据缓冲区。

我们采用相同的状态来计算偏差。首先,我们检查当前状态是否是训练数据批次中的最后一个,并将已分析状态添加到偏差矩阵的相应行中(最后一个状态不添加)。

为什么我们按原样添加状态,而这是一个偏差矩阵?答案就在下一步。在循环的每次后续迭代中,我们从偏差矩阵的前一行中减去正在分析的动作,其包含在上一步中保存的上一状态。当然,若没有上一步时,我们会跳过这一步,这是第一个状态。

         if(row > 0)
            delta.Row(delta.Row(row - 1) - inp, row - 1);

接下来,我们按顺序调用经过训练的模型的前馈验算方法。首先是编码器。

         if(!LastEncoder.BufferWrite() || 
            !Encoder.feedForward((CBufferFloat*)GetPointer(State), 1, false, (CBufferFloat*)GetPointer(LastEncoder)))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            Stop = true;
            break;
           }

它后随解码器。

         if(!Decoder.feedForward(GetPointer(Encoder), -1, (CBufferFloat*)NULL))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            Stop = true;
            break;
           }

前馈-前馈模块以噪声预测模型结束。

         if(!Noise.feedForward(GetPointer(Encoder), -1, (CBufferFloat*)NULL))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            Stop = true;
            break;
           }

如常,在前馈模块之后,我们执行训练模型的反向传播验算,其中我们调整它们的参数,从而最小化误差。首先,我们运行解码器的反向传播验算,将误差梯度传递给编码器。在我们调用模型反向传播验算之前,我们需要准备目标值。

在解码器的输出端,我们希望接收到编码器中的初始状态参数,以及对某个规划范围的预测。在上一篇文章中,我们讨论了每根烛条的预测参数的组成。我将坚持同样的观点。因此,解码器架构和准备目标值的算法都没有改变。我们首先用馈送到编码器输入的数据填充目标值矩阵。

         target.Assign(Buffer[tr].States[i].state);
         ulong size = target.Size();
         targets = matrix<float>::Zeros(1, size);
         targets.Row(target, 0);
         if(size > BarDescr)
            targets.Reshape(size / BarDescr, BarDescr);
         ulong shift = targets.Rows();

然后,我们用来自给定规划范围的经验回放缓冲区的数据对其进行补充。

         targets.Resize(shift + PrecoderBars, 3);
         for(int t = 0; t < PrecoderBars; t++)
           {
            target.Assign(Buffer[tr].States[i + t].state);
            if(size > BarDescr)
              {
               matrix<float> temp(1, size);
               temp.Row(target, 0);
               temp.Reshape(size / BarDescr, BarDescr);
               temp.Resize(size / BarDescr, 3);
               target = temp.Row(temp.Rows() - 1);
              }
            targets.Row(target, shift + t);
           }
         targets.Reshape(1, targets.Rows()*targets.Cols());
         target = targets.Row(0);

我们将接收到的信息传输到向量之中,并将其与解码器前馈结果进行比较。

         Decoder.getResults(result);
         vector<float> error = target - result;

如前,在训练过程中,我们专注于最高的偏差。故此,我们首先计算移动均方误差。

         std_count = MathMin(std_count, 999);
         STE = MathSqrt((MathPow(STE, 2) * std_count + MathPow(error, 2)) / (std_count + 1));

然后,我们将当前误差与基于标准偏差的阈值进行比较。仅在当前误差超过至少一个参数中的阈值时,才会执行反向传播验算。

         vector<float> check = MathAbs(error) - STE * STE_Multiplier;
         if(check.Max() > 0)
           {
            //---
            Result.AssignArray(CAGrad(error) + result);
            if(!Decoder.backProp(Result, (CNet *)NULL) ||
               !Encoder.backPropGradient(GetPointer(LastEncoder), GetPointer(Gradient)))
              {
               PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
               Stop = true;
               break;
              }
           }

强调最大偏差的思路是从 CFPI 方法借鉴的。

我们对噪声预测模型运用类似的反向传播算法。但在这里组织目标值向量的方法要简单得多:当工作在干净轨迹时,我们只需使用零值向量。

         target = vector<float>::Zeros(delta.Cols());
         Noise.getResults(result);
         error = (target - result) * STE_Noise_Multiplier;

注意,在计算误差时,我们将得到的偏差乘以常数 STE_Noise_Multiplier,这可判定噪声预测误差对整体模型误差的影响。

我们还专注最大偏差,并且仅在至少一个参数的误差高于阈值时才执行反向传播验算。

         STE_Noise = MathSqrt((MathPow(STE_Noise, 2) * std_count + MathPow(error, 2)) / (std_count + 1));
         std_count++;
         check = MathAbs(error) - STE_Noise;
         if(check.Max() > 0)
           {
            //---
            Result.AssignArray(CAGrad(error) + result);
            if(!Noise.backProp(Result, (CNet *)NULL) ||
               !Encoder.backPropGradient(GetPointer(LastEncoder), GetPointer(Gradient)))
              {
               PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
               Stop = true;
               break;
              }
           }

我们将噪声预测模型的误差梯度传递给编码器,并在必要时调用其反向传播方法。

在更新所有训练模型的参数后,我们将编码器前馈验算的最新结果保存到辅助缓冲区当中。

         Encoder.getResults(result);
         LastEncoder.AssignArray(result);

我们告知用户学习过程的进度,并转入嵌套循环的下一次迭代。

         if(GetTickCount() - ticks > 500)
           {
            double percent = (double(i - state) / (2 * (end - state)) + iter) * 100.0 / (Iterations);
            string str = StringFormat("%-20s %6.2f%% -> Error %15.8f\n", "Decoder", percent, 
                                                                    Decoder.getRecentAverageError());
            str += StringFormat("%-20s %6.2f%% -> Error %15.8f\n", "Noise Prediction", percent, 
                                                                      Noise.getRecentAverageError());
            Comment(str);
            ticks = GetTickCount();
           }
        }

于此我们通常完成模型训练循环系统中的迭代描述。但这次情况不同。我们已经在干净轨迹上处理了一批训练模型。现在我们需要对加噪的轨迹重复操作。故此,此处我们首先定义噪声分布的统计参数。

      //--- With noise
      vector<float> std_delta = delta.Std(0) * STD_Delta_Multiplier;
      vector<float> mean_delta = delta.Mean(0);

注意,标准差乘以噪声因子,以减少分析特征值中的最大可能偏差。 

我们创建一个向量,和一个数组来生成噪声。

      ulong inp_total = std_delta.Size();
      vector<float> noise = vector<float>::Zeros(inp_total);
      double ar_noise[];

之后,我们对新轨迹及其初始状态进行采样。

      tr = SampleTrajectory(probability);
      state = (int)((MathRand() * MathRand() / MathPow(32767, 2)) * (Buffer[tr].Total - 3 - PrecoderBars - batch));
      if(state < 0)
        {
         iter--;
         continue;
        }

然后我们清除模型堆栈和辅助缓冲区。

      Encoder.Clear();
      Decoder.Clear();
      Noise.Clear();
      LastEncoder.BufferInit(EmbeddingSize, 0);

然后我们创建另一个嵌套循环来处理加噪轨迹。

      end = MathMin(state + batch, Buffer[tr].Total - PrecoderBars);
      for(int i = state; i < end; i++)
        {
         if(!Math::MathRandomNormal(0, 1, (int)inp_total, ar_noise))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            Stop = true;
            break;
           }
         noise.Assign(ar_noise);

在循环体中,我们首先从正态分布中生成噪声,并将其传递至向量。之后,我们重新参数化它。

         noise = mean_delta + std_delta * noise;

在这个阶段,我们已经为当前的训练迭代准备了噪声。我们从经验回放缓冲区加载干净状态,并将生成的噪声添加到该状态之中。

         inp.Assign(Buffer[tr].States[i].state);
         inp = inp + noise;

生成的加噪状态被加载到源数据缓存区当中。

         State.AssignArray(inp);

接下来,我们执行一个前馈模块,类似于操控干净轨迹。

         if(!LastEncoder.BufferWrite() || 
            !Encoder.feedForward((CBufferFloat*)GetPointer(State), 1, false, (CBufferFloat*)GetPointer(LastEncoder)))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            Stop = true;
            break;
           }
         if(!Decoder.feedForward(GetPointer(Encoder), -1, (CBufferFloat*)NULL))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            Stop = true;
            break;
           }
         if(!Noise.feedForward(GetPointer(Encoder), -1, (CBufferFloat*)NULL))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            Stop = true;
            break;
           }

根据 SSWNP 方法,我们应该在干净和加噪轨迹的预测轨迹之间建立空间一致性。正如我们在理论部分所看到的,两条轨迹都朝着同一个目标收敛。因此,我们如上面干净轨迹所做,以相同的方式构造解码器的反向传播模块。

         target.Assign(Buffer[tr].States[i].state);
         ulong size = target.Size();
         targets = matrix<float>::Zeros(1, size);
         targets.Row(target, 0);
         if(size > BarDescr)
            targets.Reshape(size / BarDescr, BarDescr);
         ulong shift = targets.Rows();
         targets.Resize(shift + PrecoderBars, 3);
         for(int t = 0; t < PrecoderBars; t++)
           {
            target.Assign(Buffer[tr].States[i + t].state);
            if(size > BarDescr)
              {
               matrix<float> temp(1, size);
               temp.Row(target, 0);
               temp.Reshape(size / BarDescr, BarDescr);
               temp.Resize(size / BarDescr, 3);
               target = temp.Row(temp.Rows() - 1);
              }
            targets.Row(target, shift + t);
           }
         targets.Reshape(1, targets.Rows()*targets.Cols());
         target = targets.Row(0);
         Decoder.getResults(result);
         vector<float> error = target - result;
         std_count = MathMin(std_count, 999);
         STE = MathSqrt((MathPow(STE, 2) * std_count + MathPow(error, 2)) / (std_count + 1));
         vector<float> check = MathAbs(error) - STE * STE_Multiplier;
         if(check.Max() > 0)
           {
            //---
            Result.AssignArray(CAGrad(error) + result);
            if(!Decoder.backProp(Result, (CNet *)NULL) ||
               !Encoder.backPropGradient(GetPointer(LastEncoder), GetPointer(Gradient)))
              {
               PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
               Stop = true;
               break;
              }
           }

对于噪声预测模型,区别在于目标值。对于干净轨迹,我们采用一个填充为零值的向量,现在我们使用添加到干净状态的噪声,然后再将其作为目标值馈送到编码器输入。

         target = noise;
         Noise.getResults(result);
         error = (target - result) * STE_Noise_Multiplier;
         STE_Noise = MathSqrt((MathPow(STE_Noise, 2) * std_count + MathPow(error, 2)) / (std_count + 1));
         std_count++;
         check = MathAbs(error) - STE_Noise;
         if(check.Max() > 0)
           {
            //---
            Result.AssignArray(CAGrad(error) + result);
            if(!Noise.backProp(Result, (CNet *)NULL) ||
               !Encoder.backPropGradient(GetPointer(LastEncoder), GetPointer(Gradient)))
              {
               PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
               Stop = true;
               break;
              }
           }

更新模型参数后,我们将最后一个编码器的验算结果保存到辅助缓冲区。

         Encoder.getResults(result);
         LastEncoder.AssignArray(result);

我们通知用户学习过程的进度,并转到循环的下一次迭代。

         if(GetTickCount() - ticks > 500)
           {
            double percent = (double(i - state) / (2 * (end - state)) + iter + 0.5) * 100.0 / (Iterations);
            string str = StringFormat("%-20s %6.2f%% -> Error %15.8f\n", "Decoder", percent, 
                                                                          Decoder.getRecentAverageError());
            str += StringFormat("%-20s %6.2f%% -> Error %15.8f\n", "Noise Prediction", percent, 
                                                                            Noise.getRecentAverageError());
            Comment(str);
            ticks = GetTickCount();
           }
        }
     }

模型训练循环系统中迭代的讲述到此结束。所有迭代成功完成之后,我们清除金融产品图表上的注释字段。

   Comment("");
//---
   PrintFormat("%s -> %d -> %-20s %10.7f", __FUNCTION__, __LINE__, "Decoder", Decoder.getRecentAverageError());
   PrintFormat("%s -> %d -> %-20s %10.7f", __FUNCTION__, __LINE__, "Noise Prediction", Noise.getRecentAverageError());
   ExpertRemove();
//---
  }

将训练过程的结果打印到日志中,并启动 EA 终止。

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

以上是轨迹函数训练 EA 的更新算法。政策训练算法维持不变。其详细讲述已在上一篇文章中给出。EA 的完整代码 “...\Experts\SSWNP\Study.mq5” 附于文后。 


3. 测试

在本文的实践部分,我们将“自监督路标噪声预测”方法集成到之前构建的轨迹函数训练 EA 当中,并配以“目标条件预测编码”方法。现在我们预计价格走势预测品质会有所提高。现在是时候在 MetaTrader 5 策略测试器中依据真实数据测试结果了。

如前,这些模型依据 EURUSD H1 的历史数据进行了训练和测试。该模型取 2023 年前 7 个月的数据进行训练。为了测试已训练模型,我们用到了 2023 年 8 月的历史数据。如您所见,测试区间紧随训练区间之后。

在训练模型之前,我们需要收集一个主要的训练数据集。由于我们在之前构建的 EA 中实现了新方法,且并无更改模型架构和数据结构,因此我们可以跳过此步骤,并使用 GCPC 方法训练模型时创建的现有样本数据库。我们创建名为 “SSWNP.bd” 的经验回放缓冲区文件的副本。然后我们直接转到模型训练过程。

根据 GCPC 方法算法,模型分 2 个阶段进行训练。在第一阶段,我们训练轨迹函数。此阶段包含新 SSWNP 方法。只有历史价格走势和指标数据被馈送到编码器输入之中。这令经验回放缓冲区中的所有轨迹都相同,因为在此阶段不会分析对轨迹产生差异的帐户状态和持仓值。因此,我们可以使用现有的样本数据库,并训练轨迹函数,直到获得可接受的结果,而无需收集额外的样本。

模型训练的第二阶段,行为政策训练,涉及在历史市场条件下搜索个体的最优动作,以及账户状态和持仓的变化,这取决于市场条件和个体执行的动作。在这个阶段,我们使用迭代模型训练,在训练模型之间交替,并收集额外的样本,令我们能够更准确地评估个体的更新行为政策。

我们的训练过程展现出一些成果。我们设法训练了一个能够在训练数据集的历史数据和测试时间段内均产生盈利的模型。

经训练模型的测试结果 经训练模型的测试结果



结束语

在本文中,我们介绍了“自监督路标噪声预测”方法。这种方式可以提高复杂随机环境中的模型效率,其中个体的未来轨迹由于不断变化的条件和物理约束而受到不确定性的影响。这一目标是通过将噪声添加到过去的轨迹中来实现的,这有助于对未来路径进行更准确和多样化的预测。所展示的创新方法由两个模块组成:空间一致性模块、和噪声预测模块,它们共同为随机场景中的准确可靠预测提供支持。

方法作者提议的构造是相当通用的,这令它可以集成到各种不同的模型训练算法之中。而不仅仅适用于强化学习方法。在他们的论文中,该方法的作者展示了所提议方法的实现如何提升基本方法效率的示例。

在本文的实践部分,我们将 SSWNP 方法提议的方式集成到 GCPC 算法的结构之中。我们的测试结果证实了所提议方法的有效性。

然而,我想再次提醒您,本文中展示的所有程序仅用于演示该技术,并不准备在现实世界的金融交易中运用。


参考


文中所用程序

# 已发行 类型 说明
1 Research.mq5 智能交易系统 样本收集 EA
2 ResearchRealORL.mq5
智能交易系统
用于使用 Real-ORL 方法收集示例的 EA
3 Study.mq5  智能交易系统 政策训练 EA
4 StudyEncoder.mq5 智能交易系统
运用 SSWNP 方式的自动编码器训练 EA
5 Test.mq5 智能交易系统 模型测试 EA
6 Trajectory.mqh 类库 系统状态定义结构
7 NeuroNet.mqh 类库 创建神经网络的类库
8 NeuroNet.cl 代码库 OpenCL 程序代码库



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

附加的文件 |
MQL5.zip (789.06 KB)
开发多币种 EA 交易(第 4 部分):虚拟挂单和保存状态 开发多币种 EA 交易(第 4 部分):虚拟挂单和保存状态
在开始开发多币种 EA 后,我们已经取得了一些成果,并成功地进行了多次代码改进迭代。但是,我们的 EA 无法处理挂单,也无法在终端重启后恢复运行。让我们添加这些功能。
开发回放系统(第 43 部分):Chart Trade 项目(II) 开发回放系统(第 43 部分):Chart Trade 项目(II)
大多数想要或梦想学习编程的人实际上并不知道自己在做什么。他们的活动包括试图以某种方式创造事物。然而,编程并不是为了定制合适的解决方案。这样做会产生更多的问题而不是解决方案。在这里,我们将做一些更高级、更与众不同的事情。
新手在交易中的10个基本错误 新手在交易中的10个基本错误
新手在交易中会犯的10个基本错误: 在市场刚开始时交易, 获利时不适当地仓促, 在损失的时候追加投资, 从最好的仓位开始平仓, 翻本心理, 最优越的仓位, 用永远买进的规则进行交易, 在第一天就平掉获利的仓位,当发出建一个相反的仓位警示时平仓, 犹豫。
改编版 MQL5 网格对冲 EA(第 II 部分):制作一款简单的网格 EA 改编版 MQL5 网格对冲 EA(第 II 部分):制作一款简单的网格 EA
在本文中,我们探讨了经典的网格策略,详解 MQL5 的智能交易系统的自动化,并初步分析回测结果。我们强调了该策略对高持有能力的需求,并概括了在未来分期分批优化距离、止盈和手数等关键参数的计划。该系列旨在提高交易策略效率,以及针对不同市场条件的适配性。