English Русский Español Deutsch 日本語 Português
preview
神经网络变得轻松(第三十部分):遗传算法

神经网络变得轻松(第三十部分):遗传算法

MetaTrader 5交易系统 | 27 一月 2023, 08:09
1 066 1
Dmitriy Gizlyk
Dmitriy Gizlyk

内容

概述

我们将继续研究模型训练算法。 之前研究过的所有算法都会用到一种分析方法来判定学习过程中模型参数变化的方向和强度。 这为所有模型设定了主要需求:模型函数必须在整个数值范围内可微分。 此属性允许运用梯度下降方法。 它还令我们能够判定每个模型参数对整体结果的影响,并校正权重系数,从而减少误差。

然而,当无法区分原始函数时,会存在少量问题。 这些也许是不可微函数,或模型表现出爆炸性、或衰减梯度等问题。 而解决这些问题的方法变得效率低下。 在这种情况下,我们诉诸进化优化方法。


1. 进化优化方法

进化优化方法被称为无梯度方法。 它们允许优化以前研究过的方法无法优化的模型。 然而,还有许多其它应用方式。 有时,观察如何利用进化方法和其它方法训练模型很有趣,这意味着误差梯度下降算法的应用。

该方法的主要思路是从自然科学中借鉴而来的。 特别是来自达尔文的进化论。 根据这一理论,任何生物种群都足以生育后代,并令种群增长。 但有限的生活资源限制了种群增长。 这就是自然选择发挥关键作用的地方。 这意味着适者生存。 即,那些最适应环境的个体才能幸存下来。 因此,每一代,种群都会更好地发展和适应环境。 种群成员发展出新的属性和能力,帮助它们生存。 此外,它们忘记了一切无关紧要的事情。

但是这种理论上的高度简洁概括不包含任何数学。 当然,可以根据可用资源的总数及其消耗,来计算最大可能的种群规模。 然而,这并不影响该理论的一般原则。

正是由该理论作为构成整个进化方法家族的原型。 在本文中,我建议熟悉遗传优化算法。 它是进化方法的基本算法之一。 该算法基于达尔文进化论的两个主要假设:遗传和自然选择。

该方法的本质是观察种群的每一代,并选择其最佳代表。 但首要之事依然是首要。

由于我们观察的是整个种群,因此基本要求是每一代的寿命有限。 与之前研究的强化学习算法类似,这里的过程必须是有限的。 好了,在此我们将采用相同的方法。 特别是,场次的时间有限。

如上所述,我们将观察整个种群。 因此,与之前讨论的算法不同,我们创建的不是一个模型,而是整个种群。 种群在相同的条件下同时“生存”。 种群规模是一个超参数,它决定了种群探索环境的能力。 每个种群成员都根据其个体政策采取行动。 相应地,观察的种群越大,我们观察到的不同策略就越多。 相应地,环境研究越好。

这个过程可与强化学习期间相同状态下代理者动作的重复随机选择进行比较。 但是现在我们同时使用多个代理者,每个代理者都有自己的选择。

使用独立总体种群成员便于并行化优化过程。 最常用的,为了减少最佳模型搜索时间,优化过程在多台机器上利用所有可用资源并行运行。 在这种情况下,种群的每个成员都“生存在”自己的微处理器线程当中。 整个优化过程由节点机控制和处理,节点机评估每个代理者的结果,并生成新的种群。

自然选择是在一代个体场次结束后执行的。 这个过程从整个种群中选择最好的代表,然后繁衍后代。 这意味着它们产生了新一代的种群。 最佳代表的数量是一个超参数,通常表示为总种群规模的比例。

选择最佳代表的准则取决于优化过程的架构。 例如,它们可以使用奖励,就像我们在强化学习中所做的那样。 备选项是,它们可以像监督学习一样使用损失函数。 相应地,我们将选择具有最大总奖励或损失函数最小值的代理者。

请注意,我们没有使用误差梯度。 因此,我们可以利用不可微函数来选择最佳代表。

在为未来的后代选择亲本之后,我们需要创造新一代的种群。 为此,我们从选定的最佳代表中随机选择几个模型 — 它们将作为新模型的亲本模型。 选择一对来创建新模型是不是象征性的?

在创建新模型的过程中,其所有参数都被视为染色体。 每个单独的权重都是遗传自亲本之一的单独基因。

继承算法也许会不同,但它们都基于两个规则:

  • 每个基因都不会改变其位置
  • 随机选择每个基因的亲本

我们可以为新一代种群的每个成员随机选择亲本,或者我们可以创建一对具有基因镜像遗传的代理者。

循环重复这个过程,直到新一代种群完全填满。 在新一代种群中不包括以前选择的亲本。 它们在产生后代后即被删除。

针对新的一代,我们开始一个新场次,并重复优化过程。

请注意,我故意说“优化”而不是“学习”。 上述过程与学习几乎没有相似之处。 这是进化过程中的纯粹自然选择。 如您所知,进化过程中有各种突变,但并不常见。 但它们是进化过程中不可或缺的一个环节。 故此,我们还会在优化过程中增加一些不确定性。

这听起来也许很奇怪。 在优化过程中,几乎所有内容都基于随机选择。 首先,我们随机生成第一个种群。 然后我们随机选择亲本。 最后,我们随机复制模型参数。 但所有这些随机性背后并无新鲜感。 由此,我们通过突变来增加新颖性。

我们在优化过程中添加另一个超参数,它将负责产生一些突变。 它替代复制,表示将随机基因添加到新后代的概率。 换言之,群体中的每个新成员都接受一个具有突变参数概率的随机基因,替代从亲本那里继承。 因此,除了继承自亲本之外,每一代都会引入新的东西。 这与我们的发展有最大相似之处。


2. 利用 MQL5 实现

在研究了算法的理论层面之后,我们继续讨论本文的实施部分。 我们将利用 MQL5 实现所研究的算法。 当然,所提出的算法中几乎不含数学。 但它还有别的东西 — 清晰的构建的一套动作算法。 这就是我们即将实现的。

我们之前构建的模型不适合解决此类问题。 在构建处理神经网络的 CNet 类时,我们希望仅使用单个线性模型。 这次,我们需要实现几个线性模型的并行运算。 有两种途径可以解决此问题。

第一个对程序员来说劳动密集度较低,但资源密集度较高:我们可以简单地创建一个动态的对象数组,在其中创建几个相同的模型。 然后,我们交替地从数组中提取模型,并逐个处理它们。 在此变体中,每个单独模型的所有操作都将在现有功能的框架内实现。 我们只需要实现选择亲本,并产生新一代的方法,以及代理者选择过程。

此方法的缺点包括资源消耗过高,以及需要创建大量额外对象。 对于每个代理者,我们需要创建一个单独的类实例来处理 OpenCL 关联环境。 除此之外,我们还要创建了一个单独的关联环境,包括程序和所有内核对象的副本。 当并行使用多个计算设备时,这是可以接受的。 否则,创建额外的对象会导致资源使用效率低下,并严重限制种群规模。 这反过来又会对优化过程的结果产生负面影响。

故此,我决定修改我们的类,从而可操控神经网络模型。 但为了不破坏工作流程,我创建了一个新类 CNetGenetic,该类继承自公开类 CNet

class CNetGenetic : public CNet
  {
protected:
   uint              i_PopulationSize;
   vector            v_Probability;
   vector            v_Rewards;
   matrixf           m_Weights;
   matrixf           m_WeightsConv;

   //---
   bool              CreatePopulation(void);
   int               GetAction(CBufferFloat * probability);
   bool              GetWeights(uint layer);
   float             NextGenerationWeight(matrixf &array, uint shift, vector &probability);
   float             GenerateWeight(uint total);

public:
                     CNetGenetic();
                    ~CNetGenetic();
   //---
   bool              Create(CArrayObj *Description, uint population_size);
   bool              SetPopulationSize(uint size);
   bool              feedForward(CArrayFloat *inputVals, int window = 1, bool tem = true);
   bool              Rewards(CArrayFloat *targetVals);
   bool              NextGeneration(double quantile, double mutation, double &average, double &mamximum);
   bool              Load(string file_name, uint population_size, bool common = true);
   bool              SaveModel(string file_name, int model, bool common = true);
   //---
   bool              CopyModel(CArrayLayer *source, uint model);
   bool              Detach(void);
  };

我将提供方法用途及其实现的解释。 现在,我们来看一下变量:

  • i_PopulationSize — 种群规模
  • v_Probability — 选择模型作为亲本的概率的向量
  • v_Rewards — 由每个独立模型累积的总奖励的向量
  • m_Weights — 记录所有模型参数的矩阵
  • m_WeightsConv — 记录卷积神经层所有参数的类似矩阵

在类构造函数中,我们只初始化上述变量。 在此,我们设置默认的种群规模,并调用更改相应变量的方法。

CNetGenetic::CNetGenetic() :  i_PopulationSize(100)
  {
   SetPopulationSize(i_PopulationSize);
  }

该类未用到其它对象的实例。 因此,类的析构函数保持为空。

我们已经提到了指定种群规模的方法 SetPopulationSize。 它的算法非常简单。 在参数中,该方法接收种群规模。 在方法的主体中,我们将接收到的值保存在相应的变量中,并用零值初始化概率和奖励的向量。

bool CNetGenetic::SetPopulationSize(uint size)
  {
   i_PopulationSize = size;
   v_Probability = vector::Zeros(i_PopulationSize);
   v_Rewards = vector::Zeros(i_PopulationSize);
//---
   return true;
  }

接下来,我们看一下 Create 类对象初始化方法。 与父类的类似方法类似,该方法在参数中接收指向一个代理者的描述对象的指针。 我们还添加了种群规模。

bool CNetGenetic::Create(CArrayObj *Description, uint population_size)
  {
   if(CheckPointer(Description) == POINTER_INVALID)
      return false;
//---
   if(!SetPopulationSize(population_size))
      return false;
   CNet::Create(Description);
   return CreatePopulation();
  }

在方法主体中,我们首先检查收到的指向模型架构描述对象指针的有效性。 验证成功后,调用已知方法来指定种群规模。

接下来,调用父类的类似方法,在其中将根据收到的描述创建一个代理者,并初始化所有其它对象。

且最后,调用种群创建方法 CreatePopulation,在其中通过复制先前创建的模型来填充种群。 我们仔细看看该方法的算法。

在方法开始时,我们检查所创建模型中的神经层数。 必须至少有两层。

bool CNetGenetic::CreatePopulation(void)
  {
   if(!layers || layers.Total() < 2)
      return false;

接下来,将指向源数据神经层的指针保存到局部变量当中。

   CLayer *layer = layers.At(0);
   if(!layer || !layer.At(0))
      return false;
//---
   CNeuronBaseOCL *neuron_ocl = layer.At(0);
   int prev_count = neuron_ocl.Neurons();

请注意,第一神经层仅用于记录源数据。 我们种群的所有代理者都将取用相同的源数据。 因此,按种群中的代理者数量复制源数据层是没有意义的。 神经层的复制从索引为 1 的下一个神经层开始。

我们回顾一下神经网络对象的结构。 CNet 类负责在顶层组织模型的操作。 它包含神经层动态数组的 CArrayLayer 对象实例。 在这个动态数组中,我们直接从 CLayer 神经层存储指向嵌套动态数组对象的指针。 在那里,我们将编写指向神经元对象 CNeuronBaseOCL 和其它对象的指针。

CNet -> CArrayLayer -> CLayer -> CNeuronBaseOCL

此结构最初是在我们在 CPU 上以 MQL5 实现计算过程时创建的。 每个单独的神经元都是一个单独的对象。 后来,当我们利用 OpenCL 技术将计算转移动到 GPU 上时,我们被迫采用数据缓冲区。 实际上,每个神经层都由一个 CNeuronBaseOCL 神经元中表达,其执行神经层的功能。 这同样适用于其它类型的神经元。

因此,CLayer 神经层的每个对象现在只包含一个神经元对象。 以前,我们没有更改数据存储体系结构,这是为了保持与之前版本的兼容性。 这个事实现在有另一层重要性。 我们将简单地把所需数量的对象添加到 CLayer 动态数组当中,以便存储我们的整个代理者种群。 因此,在一个模型中,我们拥有的并行对象涵盖我们的群体中所有代理者的神经层。 如此,我们只需要根据相应的代理者索引来实现它们的操作。

遵循这个逻辑,我们随之创建一个循环来复制神经层。 在这个循环中,我们将遍历模型的所有神经层,并添加所需数量的神经元,类似于之前在每一层中创建的第一个神经元。

在循环体中,我们首先检查指向之前所创建神经层指针的有效性。

   for(int i = 1; i < layers.Total(); i++)
     {
      layer = layers.At(i);
      if(!layer || !layer.At(0))
         return false;
      //---

然后获取神经元结构的描述。

      neuron_ocl = layer.At(0);
      CLayerDescription *desc = neuron_ocl.GetLayerInfo();
      int outputs = neuron_ocl.getConnections();

创建类似的对象,并将神经层填充到所需的种群规模。 为此目的,我们需要创建另一个嵌套循环。

      for(uint n = layer.Total(); n < i_PopulationSize; n++)
        {
         CNeuronConvOCL *neuron_conv_ocl = NULL;
         CNeuronProofOCL *neuron_proof_ocl = NULL;
         CNeuronAttentionOCL *neuron_attention_ocl = NULL;
         CNeuronMLMHAttentionOCL *neuron_mlattention_ocl = NULL;
         CNeuronDropoutOCL *dropout = NULL;
         CNeuronBatchNormOCL *batch = NULL;
         CVAE *vae = NULL;
         CNeuronLSTMOCL *lstm = NULL;
         switch(layer.At(0).Type())
           {

            case defNeuron:
            case defNeuronBaseOCL:
               neuron_ocl = new CNeuronBaseOCL();
               if(CheckPointer(neuron_ocl) == POINTER_INVALID)
                  return false;
               if(!neuron_ocl.Init(outputs, n, opencl, desc.count, desc.optimization, desc.batch))
                 {
                  delete neuron_ocl;
                  return false;
                 }
               neuron_ocl.SetActivationFunction(desc.activation);
               if(!layer.Add(neuron_ocl))
                 {
                  delete neuron_ocl;
                  return false;
                 }
               neuron_ocl = NULL;
               break;

            case defNeuronConvOCL:
               neuron_conv_ocl = new CNeuronConvOCL();
               if(CheckPointer(neuron_conv_ocl) == POINTER_INVALID)
                  return false;
               if(!neuron_conv_ocl.Init(outputs, n, opencl, desc.window, desc.step, desc.window_out,
                                                           desc.count, desc.optimization, desc.batch))
                 {
                  delete neuron_conv_ocl;
                  return false;
                 }
               neuron_conv_ocl.SetActivationFunction(desc.activation);
               if(!layer.Add(neuron_conv_ocl))
                 {
                  delete neuron_conv_ocl;
                  return false;
                 }
               neuron_conv_ocl = NULL;
               break;

            case defNeuronProofOCL:
               neuron_proof_ocl = new CNeuronProofOCL();
               if(!neuron_proof_ocl)
                  return false;
               if(!neuron_proof_ocl.Init(outputs, n, opencl, desc.window, desc.step, desc.count,
                                                                   desc.optimization, desc.batch))
                 {
                  delete neuron_proof_ocl;
                  return false;
                 }
               neuron_proof_ocl.SetActivationFunction(desc.activation);
               if(!layer.Add(neuron_proof_ocl))
                 {
                  delete neuron_proof_ocl;
                  return false;
                 }
               neuron_proof_ocl = NULL;
               break;

            case defNeuronAttentionOCL:
               neuron_attention_ocl = new CNeuronAttentionOCL();
               if(CheckPointer(neuron_attention_ocl) == POINTER_INVALID)
                  return false;
               if(!neuron_attention_ocl.Init(outputs, n, opencl, desc.window, desc.count, desc.optimization, desc.batch))
                 {
                  delete neuron_attention_ocl;
                  return false;
                 }
               neuron_attention_ocl.SetActivationFunction(desc.activation);
               if(!layer.Add(neuron_attention_ocl))
                 {
                  delete neuron_attention_ocl;
                  return false;
                 }
               neuron_attention_ocl = NULL;
               break;

            case defNeuronMHAttentionOCL:
               neuron_attention_ocl = new CNeuronMHAttentionOCL();
               if(CheckPointer(neuron_attention_ocl) == POINTER_INVALID)
                  return false;
               if(!neuron_attention_ocl.Init(outputs, n, opencl, desc.window, desc.count, desc.optimization, desc.batch))
                 {
                  delete neuron_attention_ocl;
                  return false;
                 }
               neuron_attention_ocl.SetActivationFunction(desc.activation);
               if(!layer.Add(neuron_attention_ocl))
                 {
                  delete neuron_attention_ocl;
                  return false;
                 }
               neuron_attention_ocl = NULL;
               break;

            case defNeuronMLMHAttentionOCL:
               neuron_mlattention_ocl = new CNeuronMLMHAttentionOCL();
               if(CheckPointer(neuron_mlattention_ocl) == POINTER_INVALID)
                  return false;
               if(!neuron_mlattention_ocl.Init(outputs, n, opencl, desc.window, desc.window_out,
                                               desc.step, desc.count, desc.layers, desc.optimization, desc.batch))
                 {
                  delete neuron_mlattention_ocl;
                  return false;
                 }
               neuron_mlattention_ocl.SetActivationFunction(desc.activation);
               if(!layer.Add(neuron_mlattention_ocl))
                 {
                  delete neuron_mlattention_ocl;
                  return false;
                 }
               neuron_mlattention_ocl = NULL;
               break;

添加对象的算法类似于在父类中创建新对象。

一个神经网络的所有种群元素一旦添加完毕后,把层数与种群规模对齐,并删除神经元描述对象。

        }
      if(layer.Total() > (int)i_PopulationSize)
         layer.Resize(i_PopulationSize);
      delete desc;
     }
//---
   return true;
  }

一旦循环系统的所有迭代都完成,我们将在单个模型实例中获得全部种群,并以正面结果退出该方法。

此方法和整个类的完整代码均可在附件中找到。

方法完成 CNetGene 类对象的初始化之后,即开始定义前馈方法。 它的名称和参数重复父类方法中所用的内容。 它包含一个指向源数据动态数组对象的指针,以及用于创建源数据时间戳的参数。

在方法主体中,检查收到的指针和所用部对象的有效性。

bool CNetGenetic::feedForward(CArrayFloat *inputVals, int window = 1, bool tem = true)
  {
   if(CheckPointer(layers) == POINTER_INVALID || CheckPointer(inputVals) == POINTER_INVALID || layers.Total() <= 1)
      return false;

准备局部变量。

   CLayer *previous = NULL;
   CLayer *current = layers.At(0);
   int total = MathMin(current.Total(), inputVals.Total());
   CNeuronBase *neuron = NULL;
   if(CheckPointer(opencl) == POINTER_INVALID)
      return false;
   CNeuronBaseOCL *neuron_ocl = current.At(0);
   CBufferFloat *inputs = neuron_ocl.getOutput();
   int total_data = inputVals.Total();
   if(!inputs.Resize(total_data))
      return false;

将源数据移到源数据层缓冲区,并将其写入 OpenCL 关联环境。 如有必要,添加时间戳。

   for(int d = 0; d < total_data; d++)
     {
      int pos = d;
      int dim = 0;
      if(window > 1)
        {
         dim = d % window;
         pos = (d - dim) / window;
        }
      float value = pos / pow(10000, (2 * dim + 1) / (float)(window + 1));
      value = (float)(tem ? (dim % 2 == 0 ? sin(value) : cos(value)) : 0);
      value += inputVals.At(d);
      if(!inputs.Update(d, value))
         return false;
     }
   if(!inputs.BufferWrite())
      return false;

之后,创建一个循环系统,为所分析种群的所有代理者实现前馈验算。 外循环将按升序遍历神经层。 嵌套循环将迭代遍历代理者。

请注意,在指定前一层的神经元时,我们必须明确控制代理者的对应关系。 每个代理者在其自己的垂直神经元中运行,这由层中神经元的序列号决定。 与此同时,我们没有复制原始数据层。 因此,在指定前一层对应神经元的索引时,我们首先检查神经层本身的序列号。 对于源数据层,前一层神经元的序列号将始终为 0。 对于所有其它层,它则对应于代理者的序列号。

由于所有代理者都是绝对独立的,因此我们可以针对所有代理者同时执行操作。

   for(int l = 1; l < layers.Total(); l++)
     {
      previous = current;
      current = layers.At(l);
      if(CheckPointer(current) == POINTER_INVALID)
         return false;
      //---
      for(uint n = 0; n < i_PopulationSize; n++)
        {
         CNeuronBaseOCL *current_ocl = current.At(n);
         if(!current_ocl.FeedForward(previous.At(l == 1 ? 0 : n)))
            return false;
         continue;
        }
     }
//---
   return true;
  }

当然,使用循环并不能提供计算的完全并行性。 但与此同时,我们将依次针对所有代理者实现类似的迭代。 这将允许所有代理者使用生成的源数据。 顺推之,这又会降低为每个单独的代理者准备源数据时的成本。

不要忘记控制每一步的结果。 一旦嵌套循环系统的所有迭代完成后,退出该方法。

遗传算法中不存在误差梯度的反向传播。 然而,我们需要评估模型的性能。 在本文中,我将优化上一篇文章中的代理者,我们运用策略梯度算法对其进行了训练。 为了优化模型的性能,我们将最大化模型每个场次的总奖励。 因此,我们必须在每个动作后将其奖励返还给每个代理者。 您还记得,奖励取决于所选的动作。 每个代理者执行自己的动作。 之前,我们从代理者那里收到执行动作的概率分布,从该分布中抽取一个操作,并将相关奖励返回给代理者。 现在我们有很多这样的代理者。 为了外部程序中的每个代理者不再重复这些迭代,我们将其包装在一个单独的 Rewards 方法中。 外部程序(环境)将在其参数中传递所有可能动作的奖励。 这种方式允许我们每个动作只评估一次,无论用到的代理者数量如何。

在方法主体中,首先检查指向参数中收到的奖励向量指针的有效性,以及神经层的动态数组。

bool CNetGenetic::Rewards(CArrayFloat *rewards)
  {
   if(!rewards || !layers || layers.Total() < 2)
      return false;

接下来,从动态数组中提取指向代理者结果层的指针,并检查收到的指针的有效性。

   CLayer *output = layers.At(layers.Total() - 1);
   if(!output)
      return false;

之后,创建一个循环来迭代和查询我们的种群中的所有代理者。 对于每个代理者,我们从相应的分布中采样一个动作。 取决于所选动作,代理者会收到其奖励,并被加到早前由代理者索引指定的 v_Rewards 向量中收到的奖励当中。

   for(int i = 0; i < output.Total(); i++)
     {
      CNeuronBaseOCL *neuron = output.At(i);
      if(!neuron)
         return false;
      int action = GetAction(neuron.getOutput());
      if(action < 0)
         return false;
      v_Rewards[i] += rewards.At(action);
     }

基于代理者的评估结果,我们可为代理者进入下一代亲本的数量制作概率分布。

   v_Probability = v_Rewards - v_Rewards.Min();
   if(!v_Probability.Clip(0, v_Probability.Max()))
      return false;
   v_Probability = v_Probability / v_Probability.Sum();
//---
   return true;
  }

然后以肯定结果退出方法。 下面的附件中提供了所有方法和类的完整代码。

所创建功能足以为实现分析所分析种群的每个单独场次,并评估代理者动作。 一旦场次结束,我们需要选出最好的代表,并产生新一代的种群。 此功能将在 NextGeneration 方法中实现。 在此方法的参数中,我们将传递两个超参数:要删除的个体比例,和突变参数。 此外,方法参数包含两个变量,我们将在其中返回所选代理者的平均和最大奖励。

在方法主体中,我们首先设置选择代理者的概率为零,除了已选中那个。 并计算所选候选者的最大奖励和加权平均值。

bool CNetGenetic::NextGeneration(double quantile, double mutation, double &average, double &maximum)
  {
   maximum = v_Rewards.Max();
   v_Probability = v_Rewards - v_Rewards.Quantile(quantile);
   if(!v_Probability.Clip(0, v_Probability.Max()))
      return false;
   v_Probability = v_Probability / v_Probability.Sum();
   average = v_Rewards.Average(v_Probability);

请注意,我们正在使用最近添加的向量运算。 多亏了它们,我们不必再用循环,程序代码也减少了。 vector::Max() 方法允许在一行中判定整个向量的最大值。 vector::Quantile(...) 方法返回向量的指定分位数的值。 我们依据此值来删除弱势代理者。 并在向量减法运算之后,它们的概率将变为负数。

调用 vector::Clip(0, vector::Max()) 函数,将向量的所有负值重置为零。

此外,优雅地,在一行中,我们将 0 到 1 之间的所有向量值归一化,所有元素的总值为 1。

v_Probability = v_Probability / v_Probability.Sum();

运算 vector::Average(weights) 判定向量的加权平均值。 weights 向量包含向量每个元素的权重。 早前,我们将弱势代理者的概率设置为零,因此在计算向量的加权平均值时不会考虑它们的值。

因此,向量运算的使用大大减少了程序代码,并方便了程序员的工作。 特别鸣谢 MetaQuotes 团队提供这些可能性! 有关向量和矩阵运算的详细信息,请参阅文档的相关部分。

但回到我们的方法。 我们已判定了候选者,及其概率。 现在,我们将突变比例添加到分布中,并重新计算概率。

   if(!v_Probability.Resize(i_PopulationSize + 1))
      return false;
   v_Probability[i_PopulationSize] = mutation;
   v_Probability = (v_Probability / (1 + mutation)).CumSum();

在此阶段,我们拥有了在下一代中使用代理者作为亲本的概率分布。 现在我们可以直接移到生成新的种群。 为此目的,我们实现了一个循环,在其内我们将生成新种群的每个神经层。 在神经层的每一层,我们将一次性为所有代理者生成权重矩阵。 我们将逐层来进行。

但是为了避免创建新对象,我们将简单地覆盖现有代理者的权重矩阵。 因此,在继续更新下一个神经层的权重之前,我们首先调用 GetWeights 方法,其中我们将所有代理者的当前神经层的参数复制到专门创建的 m_Weightsm_WeightsConv 矩阵之中。 在此,我们仅指示全连接层和卷积层的权重矩阵,因为它们是正在优化的模型架构中唯一用到的权重矩阵。 当使用其它神经层架构时,您需要添加相应的矩阵来临时存储参数。

   for(int l = 1; l < layers.Total(); l++)
     {
      if(!GetWeights(l))
        {
         PrintFormat("Error of load weights from layer %d", l);
         return false;
        }

收到模型参数的副本后,我们就可以开始编辑对象中的参数。 首先,我们得到一个指向神经层对象的指针。 然后,我们实现嵌套循环遍历所有代理者。 在这个循环中,我们提取一个指向相应代理者权重矩阵的指针。

      CLayer* layer = layers.At(l);
      for(uint i = 0; i < i_PopulationSize; i++)
        {
         CNeuronBaseOCL* neuron = layer.At(i);
         CBufferFloat* weights = neuron.getWeights();

如果获得的指针有效,我们实现另一个嵌套循环,其内我们将遍历权重矩阵的所有元素,并用父项的相应参数替换它们。

         if(!!weights)
           {
            for(int w = 0; w < weights.Total(); w++)
               if(!weights.Update(w, NextGenerationWeight(m_Weights, w, v_Probability)))
                 {
                  Print("Error of update weights");
                  return false;
                 }
            weights.BufferWrite();
           }

请注意,这稍微偏离了基本算法。 我们并未随机提取一对亲本。 取而代之,我们将根据其概率分布一次性从所有选定代理者中随机获取权重。 权重在 NextGenerationWeight 方法中采样。

生成下一个数据缓冲区的数值后,将其值复制到 OpenCL 关联环境当中。

如有必要,针对卷积层的矩阵重复运算。

         if(neuron.Type() != defNeuronConvOCL)
            continue;
         CNeuronConvOCL* temp = neuron;
         weights = temp.GetWeightsConv();
         for(int w = 0; w < weights.Total(); w++)
            if(!weights.Update(w, NextGenerationWeight(m_WeightsConv, w, v_Probability)))
              {
               Print("Error of update weights");
               return false;
              }
         weights.BufferWrite();
        }
     }

更新所有代理者的参数后,我们将奖励累积向量的值重置为零,以便正确判定新生一代的盈利能力。 然后以肯定结果退出方法。

   v_Rewards.Fill(0);
//---
   return true;
  }

我们已研究了主类方法的算法,它们构成了遗传算法的基础。 然而,还有若干种辅助方法。 它们的算法并不复杂,您可在附件中看到它们。 我想提请您注意模型保存方法。 关键是父类保存方法将保存所有代理者。 您可以进一步深入优化它。 但它不适用于保存单个代理者。 不过,优化的目标是找到最佳代理者。 故此,为了保存一个最佳代理者,我们将创建 SaveModel 方法。 在方法参数中,我们将传递保存模型的文件名称、代理者序列号、和写入 Common 目录的标志。

在方法的主体中,我们首先检查代理者的序列号。 如果它不满足活跃代理者的数量,则将其替换为具有最大概率的代理者数量。 这也是利润最高的代理者。

bool CNetGenetic::SaveModel(string file_name, int model, bool common = true)
  {
   if(model < 0 || model >= (int)i_PopulationSize)
      model = (int)v_Probability.ArgMax();

接下来,创建一个新模型对象的实例,并将所需模型的参数复制到其中。

   CNetGenetic *new_model = new CNetGenetic();
   if(!new_model)
      return false;
   if(!new_model.CopyModel(layers, model))
     {
      new_model.Detach();
      delete new_model;
      return false;
     }

现在我们就可以简单地调用新模型的父类保存方法。

   bool result = new_model.Save(file_name, 0, 0, 0, 0, common);

保存模型之后,在退出方法之前,我们必须删除新创建的对象。 然而,在复制数据时,我们并未创建新的神经层对象,而只是简单地利用指向它们的指针。 因此,如果我们删除模型对象,我们也要删除通用模型中所保存代理者的所有对象。 为了避免这种情况,我们首先调用 Detach 方法,它将从所保存模型中分离神经层的对象。 之后,我们就可轻松删除在此方法中创建的模型对象。

   new_model.Detach();
   delete new_model;
//---
   return result;
  }

该类所有方法的完整代码可在文后附件中找到。 现在,我们进入创建 Genetic.mq5 EA,我们将在其中实现模型优化过程。 新的 EA 将基于上一篇文章中创建的 Actor_Critic.mq5 EA。

我们在 EA 的外部参数中添加超参数,以便组织新进程。

input int                  PopulationSize =  50;
input int                  Generations = 1000;
input double               Quantile =  0.5;
input double               Mutation = 0.01;

此外,还需替换模型的工作对象。

CNetGenetic          Models;

EA 中的模型初始化的组织方式与之前研究的 EA 中父模型初始化类似。

int OnInit()
  {
//---
.............
.............
//---
   if(!Models.Load(MODEL + ".nnw", PopulationSize, false))
      return INIT_FAILED;
//---
   if(!Models.GetLayerOutput(0, TempData))
      return INIT_FAILED;
   HistoryBars = TempData.Total() / 12;
   Models.getResults(TempData);
   if(TempData.Total() != Actions)
      return INIT_PARAMETERS_INCORRECT;
//---
   bEventStudy = EventChartCustom(ChartID(), 1, 0, 0, "Init");
//---
   return(INIT_SUCCEEDED);
  }

如常,优化过程在 Train 函数中实现。 在函数开始时,与之前研究的 EA 类似,我们确定优化(训练)周期。

void Train(void)
  {
//---
   MqlDateTime start_time;
   TimeCurrent(start_time);
   start_time.year -= StudyPeriod;
   if(start_time.year <= 0)
      start_time.year = 1900;
   datetime st_time = StructToTime(start_time);

加载训练样本。

   int bars = CopyRates(Symb.Name(), TimeFrame, st_time, TimeCurrent(), Rates);
   if(!RSI.BufferResize(bars) || !CCI.BufferResize(bars) || !ATR.BufferResize(bars) || !MACD.BufferResize(bars))
     {
      ExpertRemove();
      return;
     }
   if(!ArraySetAsSeries(Rates, true))
     {
      ExpertRemove();
      return;
     }
//---
   RSI.Refresh();
   CCI.Refresh();
   ATR.Refresh();
   MACD.Refresh();

生成初始数据后,准备局部变量。 我们从训练样本中排除最后一个月 — 它将用于测试优化模型在新数据上的性能。

   CBufferFloat* State = new CBufferFloat();
   float loss = 0;
   uint count = 0;
   uint total = bars - HistoryBars - 1;
   ulong ticks = GetTickCount64();
   uint test_size=22*24;

接下来,创建一个嵌套循环系统来组织优化过程。 外部循环负责计算优化生成。 嵌套循环将计算优化迭代次数。 在本例中,我针对所有代理者完整迭代训练样本。 不过,为了减少完成一个场次所需的时间,您也可以使用随机抽样。 在这种情况下,应确保样本足够评估训练样本的主要趋势。 当然,在这种情况下,优化精度可能会降低。 此处的一个重要因素是结果准确性和模型优化成本之间的平衡。

   for(int gen = 0; (gen < Generations && !IsStopped()); gen ++)
     {
      for(uint i = total; i > test_size; i--)
        {
         uint r = i + HistoryBars;
         if(r > (uint)bars)
            continue;

在嵌套循环的主体中,我们定义当前模式的边界,并创建源数据缓冲区。

         State.Clear();
         for(uint b = 0; b < HistoryBars; b++)
           {
            uint bar_t = r - b;
            float open = (float)Rates[bar_t].open;
            TimeToStruct(Rates[bar_t].time, sTime);
            float rsi = (float)RSI.Main(bar_t);
            float cci = (float)CCI.Main(bar_t);
            float atr = (float)ATR.Main(bar_t);
            float macd = (float)MACD.Main(bar_t);
            float sign = (float)MACD.Signal(bar_t);
            if(rsi == EMPTY_VALUE || cci == EMPTY_VALUE || atr == EMPTY_VALUE || macd == EMPTY_VALUE || sign == EMPTY_VALUE)
               continue;
            //---
            if(!State.Add((float)Rates[bar_t].close - open) || !State.Add((float)Rates[bar_t].high - open) ||
               !State.Add((float)Rates[bar_t].low - open) || !State.Add((float)Rates[bar_t].tick_volume / 1000.0f) ||
               !State.Add(sTime.hour) || !State.Add(sTime.day_of_week) || !State.Add(sTime.mon) ||
               !State.Add(rsi) || !State.Add(cci) || !State.Add(atr) || !State.Add(macd) || !State.Add(sign))
               break;
           }
         if(IsStopped())
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            break;
           }
         if(State.Total() < (int)HistoryBars * 12)
            continue;

现在,为所优化种群调用前馈方法。

         if(!Models.feedForward(State, 12, true))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            break;
           }

如您所见,此过程类似于之前训练模型时执行的操作。 因为流程中的所有差别都已在函数库中实现。 方法的接口没有改变。 现在我们将调用一个模型的前向验算。 在 CNetGene 类的主体中,我们已经为种群的所有活跃代理者实现了前馈。

接下来,我们需要将当前奖励转移给代理者。 如上所述,这里我们不会轮询所有代理者。 取而代之,我们将创建一个缓冲区,在其中为给定状态下的每个动过指定奖励。 缓冲区在以下方法的参数中传递。 

         double reward = Rates[i - 1].close - Rates[i - 1].open;
         TempData.Clear();
         if(!TempData.Add((float)(reward < 0 ? 20 * reward : reward)) ||
            !TempData.Add((float)(reward > 0 ? -reward * 20 : -reward)) ||
            !TempData.Add((float) - fabs(reward)))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            break;
           }
         if(!Models.Rewards(TempData))
           {
            PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
            break;
           }

我们使用原始的奖励政策,没有任何变化。 这就可令我们能够评估优化过程对整体结果的影响。

循环处理一个系统状态的迭代一旦完成,我们将绘制相关信息,以便直观地控制流程,并继续进行循环的下一次迭代。

         if(GetTickCount64() - ticks > 250)
           {
            uint x = total - i;
            double perc = x * 100.0 / (total - test_size);
            Comment(StringFormat("%d from %d -> %.2f%% from %.2f%%", x, total - test_size, perc, 100));
            ticks = GetTickCount64();
           }
        }

一个场次结束后,保存最佳代理者的参数。

      Models.SaveModel(MODEL+".nnw", -1, false);

接下来,进入创造新一代。 这是调用一个方法来完成的 — CNetGenetic::NextGeneration。 记住要控制操作的执行。

      double average, maximum;
      if(!Models.NextGeneration(Quantile, Mutation, average, maximum))
        {
         PrintFormat("Error of create next generation: %d", GetLastError());
         PrintFormat("%s -> %d", __FUNCTION__, __LINE__);
         break;
        }
      //---
      PrintFormat("Genegation %d, Average Cumulative reward %.5f, Max Reward %.5f", gen, average, maximum);
     }

总之,在日志中打印有关已得结果的信息,并在循环的新迭代中继续评估所分析种群的新一代。

优化过程完成后,清理数据,并完成 EA 操作。

   delete State;
   Comment("");
//---
   ExpertRemove();
  }

如您所见,类的这种安排大大简化了主程序方面的工作。 在实施中,优化过程包括对类的 3 个方法的顺序调用。 这可比之使用梯度下降方法的训练模型。 这也大大减少了单个代理者中的业务总数。


3. 测试

优化过程进行测试时,采用所有以前所用的参数。 训练样本是 EURUSD H1 历史数据。 对于优化过程,我取用了过去 2 年的历史记录。 EA 均采用默认参数。 作为测试模型,我采用了上一篇文章中的架构来搜索决策的最佳概率分布。 这种方式可将优化模型替换为前面用过的 “REINFORCE-test.mq5” 智能系统。 如您所见,这是训练相同体系结构的模型过程中的第三种方法。 之前,我们已用策略梯度和扮演者-评价者算法训练了类似的模型。 故此,观察优化结果更有趣。

当优化模型时,我们没有取用上个月的数据。 因此,我们留下了一些数据来测试优化的模型。 优化的模型在策略测试器中运行。 它生成了以下结果。

已优化模型测试图

正如您从呈现的图表中所见,我们得到了一个余额不断增长的图形。 但它的盈利能力略低于采用扮演者-评价者方法训练类似模型时获得的盈利能力。 它执行的交易操作也较少。 实际上,交易次数减少了两倍。

模型交易历史图表

如果您查看带有已执行交易的品种图表,您可以清楚地看到模型试图顺势交易。 我认为这是一个有趣的结果。 当采用梯度方法训练类似的模型时,该模型尝试在大多数走势上执行交易。 很多时候,这是相当混乱的。 这一次,我们看到某种逻辑与交易中众所周知的假设一致。

或是仅我看来如此? 是否我的所有结论都是“牵强附会”? 请您自行执行实验 — 观察结果会很有趣。

测试结果表格

总的来说,与经由扮演者-评价者方法训练的模型的类似测试相比,我们看到盈利交易的占比增加了近 1.5%。 但我们的交易次数减少了 2 倍。 与此同时,我们也看到每次操作的平均损益有所下降。 所有这些都导致交易额,以及该期间的总盈利能力普遍下降。 然而,请注意,仅基于第 1 个月内的测试不能推断 EA 的长期操作。 故此,再次建议您在实盘交易之前,针对模型进行彻底和全面的测试。


结束语

在本文中,我们熟悉了优化模型的遗传方法。 它可用于优化任何参数模型。 这种方法的主要优点之一是可用它来优化不可微模型。 若采用梯度方法(包括梯度下降方法的不同变体)训练模型时,这是不可能的。

本文还包含 MQL5 的算法实现。 我们甚至优化了测试模型,并在策略测试器中观察其结果。

根据测试结果,我们可以说该模型展示出相当不错的结果。 故此,该方法可用于优化交易模型。 但在您决定在实盘账户上运行该模型之前,您必须对其进行彻底和全面的测试。

参考文献列表

  1. 神经网络变得轻松(第二十六部分):强化学习
  2. 神经网络变得轻松(第二十七部分):深度 Q-学习(DQN)
  3. 神经网络变得轻松(第二十八部分):政策梯度算法
  4. 神经网络变得轻松(第二十九部分):优势扮演者-评价者算法

本文中用到的程序

# 名称 类型 说明
1 Genetic.mq5 EA 优化模型的 EA
2 NetGenetic.mqh 类库
实现遗传算法的函数库
3 REINFORCE-test.mq5 EA
在策略测试器中测试模型的智能系统
4 NeuroNet.mqh 类库 创建神经网络模型的类库
5 NeuroNet.cl 代码库
创建神经网络模型的 OpenCL 程序代码库


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

附加的文件 |
MQL5.zip (680.14 KB)
最近评论 | 前往讨论 (1)
lbd
lbd | 26 2月 2023 在 07:29

hi, appreciate pretty much on your great effort for these series of articles, but when i try to run evolution EA,or Genetic EA, 

I got an error of 5109, which i turned to MQ5 guide book and found ,this error is caused by OPENCL...,can you tell me how to 

fix this problem? anyway, thank you very much...

ERR_OPENCL_EXECUTE

5109

OpenCL 程序 运行时间错误


DoEasy. 控件 (第 20 部分): SplitContainer WinForms 对象 DoEasy. 控件 (第 20 部分): SplitContainer WinForms 对象
在本文中,我将启动开发模拟 MS Visual Studio工具包的 SplitContainer 控件。 此控件由两个垂直或水平可移动隔板分开的面板组成。
DoEasy. 控件 (第 19 部分): 在 TabControl 中滚动选项卡、WinForms 对象事件 DoEasy. 控件 (第 19 部分): 在 TabControl 中滚动选项卡、WinForms 对象事件
在本文中,我将创建的功能是利用滚动按钮在 TabControl 中滚动选项卡标题。 该功能旨在将选项卡标题从控件的任一侧拖放到单行之中。
学习如何基于加速(Accelerator)振荡器设计交易系统 学习如何基于加速(Accelerator)振荡器设计交易系统
我们系列中的一篇新文章,介绍如何通过最流行的技术指标创建简单的交易系统。 我们将学习一个新的加速(Accelerator)振荡器指标,我们将学习如何利用它来设计交易系统。
山型或冰山型图表 山型或冰山型图表
您如何看待往 MetaTrader 5 平台里添加新图表类型的想法? 有人说它缺少其它平台里提供的一些东西。 但事实是,MetaTrader 5 是一个非常实用的平台,因为它允许您做到在许多其它平台上无法完成(或至少不能轻松完成)的事情。