神经网络变得轻松(第五部分):OpenCL 中的多线程计算

28 一月 2021, 09:18
Dmitriy Gizlyk
0
647

内容


概述

在之前的文章中,我们讨论过某些类型的神经网络实现。 如您所见,神经网络由大量相同类型的神经元组成,并在其中执行相同的操作。 然而,网络拥有的神经元越多,它消耗的计算资源也就越多。 结果就是,训练神经网络所需的时间呈指数增长,这是因为在隐藏层添加一个神经元,需要了解上一层和下一层中所有神经元的连接。 有一种减少神经网络训练时间的方法。 现代计算机的多线程功能可以同时计算多个神经元。 由于线程数量的增加,时间将可预见地大大减少。


1. MQL5 中如何组织多线程计算

MetaTrader 5 终端具有多线程体系架构。 终端中的线程分布受到严格控制。 根据文档,脚本和智能交易系统是在单独的线程中启动。 至于指示器,每个品种会提供单独的线程。 即时报价处理和历史记录同步于指标所在线程中执行。 这意味着终端只为每个智能交易系统分配一个线程。 某些计算可以在指标中执行,其可提供一个额外的线程。 然而,指标中过多的计算会减慢与即时报价数据处理相关的终端操作,这可能会导致针对市场状况的失控。 这种状况能对 EA 性能产生负面影响。

不过,有一个解决方案。 MetaTrader 5 开发人员为其提供了利用第三方 DLL 的能力。 在多线程体系结构上创建动态库会自动为函数库中实现的操作提供多线程支持。 在此,EA 操作以及与函数库之间的数据交换依然保留在智能交易系统的主线程之中。

第二个选项是利用 OpenCL 技术。 在这种情况下,我们可以用标准方法在支持该技术的处理器和视频卡上规划多线程计算。 对于此选项,程序代码不依赖所使用的设备。 该站点上有许多与 OpenCL 技术有关的出版物。 特别是,该主题在 [第五篇] 和 [第六篇] 文章里已有很好介绍。

因此,我决定使用 OpenCL。 首先,运用该技术时,用户不需要额外配置终端,并为第三方 DLL 设置权限。 其次,这样的智能交易系统可通过一个 EX5 文件在终端之间传送。 这允许将计算部分转移到视频卡,因视频卡通常在终端操作期间处于空闲状态。


2. 神经网络中的多线程计算

我们已选择了该技术。 现在,我们需要决定将计算部分拆分为线程的过程。 您还记得完全连接感知器算法吗? 信号顺序从输入层转至隐藏层,然后转至输出层。 没必要为每个层分配线程,因为计算必须按顺序执行。 直到收到来自上一层的结果之后,该层才能开始计算。 一层中独立神经元的计算不依赖该层中其他神经元的计算结果。 这意味着我们可为每个神经元分配单独的线程,并发送一整层的所有神经元进行并行计算。  

完全连接感知器

深入到一个神经元的运算,我们可以研究把计算输入值与权重系数的乘积并行化的可能性。 不过,结果值的进一步求和,以及计算激活函数的数值被合并到一个线程当中。 我决定利用 vector 函数在单个 OpenCL 内核中实现这些操作。

类似的方法也用来拆分反馈线程。 其实现如下所示。

3. 利用 OpenCL 实现多线程计算

选择了基本方法后,我们就能够继续实现了。 我们从创建内核(可执行的OpenCL函数)开始。 根据以上逻辑,我们将创建 4 个内核。

3.1. 前馈内核。

与之前文章中讨论的方法类似,我们创建一个前馈推算内核 FeedForward

不要忘记内核是在每个线程中运行的函数。 调用内核时需设置此类线程的数量。 在内核内部的操作是特定循环内的嵌套操作;循环的迭代次数等于被调用线程的次数。如此,在前馈内核中,我们可以指定计算独立神经元状态的操作,并可从主程序调用内核时以指定神经元数量。

内核从参数中接收权重矩阵,输入数据数组和输出数据数组的引用,以及输入数组的元素数量,和激活函数类型。 请注意,OpenCL 中的所有数组都是一维的。 因此,如果在 MQL5 中将二维数组用做权重系数,则此处我们需要计算初始位置的位移,以便读取第二个、及后续神经元的数据。

__kernel void FeedForward(__global double *matrix_w,
                              __global double *matrix_i,
                              __global double *matrix_o,
                              int inputs, int activation)

在内核的开头,我们获得线程的序列号,其可判定所计算神经元的序列号。 声明私密(内部)变量,包括向量变量 inp weight。 还要定义我们的神经元权重的位移。

  {
   int i=get_global_id(0);
   double sum=0.0;
   double4 inp, weight;
   int shift=(inputs+1)*i;

接下来,组织一个循环来获取输入值与其权重的乘积的合计。 如上所述,我们用到 4 个元素 inp weight 的向量来计算乘积合计。 然而,内核接收的所有数组并非都是 4 的倍数,因此缺少的元素应替换为零值。 注意输入数据向量中的一个 “1” - 它对应于贝叶斯偏差的权重。

   for(int k=0; k<=inputs; k=k+4)
     {
      switch(inputs-k)
        {
         case 0:
           inp=(double4)(1,0,0,0);
           weight=(double4)(matrix_w[shift+k],0,0,0);
           break;
         case 1:
           inp=(double4)(matrix_i[k],1,0,0);
           weight=(double4)(matrix_w[shift+k],matrix_w[shift+k+1],0,0);
           break;
         case 2:
           inp=(double4)(matrix_i[k],matrix_i[k+1],1,0);
           weight=(double4)(matrix_w[shift+k],matrix_w[shift+k+1],matrix_w[shift+k+2],0);
           break;
         case 3:
           inp=(double4)(matrix_i[k],matrix_i[k+1],matrix_i[k+2],1);
           weight=(double4)(matrix_w[shift+k],matrix_w[shift+k+1],matrix_w[shift+k+2],matrix_w[shift+k+3]);
           break;
         default:
           inp=(double4)(matrix_i[k],matrix_i[k+1],matrix_i[k+2],matrix_i[k+3]);
           weight=(double4)(matrix_w[shift+k],matrix_w[shift+k+1],matrix_w[shift+k+2],matrix_w[shift+k+3]);
           break;
        }
      sum+=dot(inp,weight);
     }

获得乘积之和后,计算激活函数,并将结果写入输出数据数组。

   switch(activation)
     {
      case 0:
        sum=tanh(sum);
        break;
      case 1:
        sum=pow((1+exp(-sum)),-1);
        break;
     }
   matrix_o[i]=sum;
  }

3.2. 反向传播内核。

为反向传播误差梯度创建两个内核。 在第一个 CaclOutputGradient 中计算输出层误差。 它的逻辑很简单。 所获参考值在激活函数的数值范围进行常规化。 然后,将参考值和实际值之间的差乘以激活函数的导数。 将结果值写入梯度数组的相应单元格中。

__kernel void CaclOutputGradient(__global double *matrix_t,
                                 __global double *matrix_o,
                                 __global double *matrix_ig,
                                 int activation)
  {
   int i=get_global_id(0);
   double temp=0;
   double out=matrix_o[i];
   switch(activation)
     {
      case 0:
        temp=clamp(matrix_t[i],-1.0,1.0)-out;
        temp=temp*(1+out)*(1-(out==1 ? 0.99 : out));
        break;
      case 1:
        temp=clamp(matrix_t[i],0.0,1.0)-out;
        temp=temp*(out==0 ? 0.01 : out)*(1-(out==1 ? 0.99 : out));
        break;
     }
   matrix_ig[i]=temp;
  }

在第二个内核中,在 CaclHiddenGradient 里计算隐藏层神经元的误差梯度。 内核构建类似于上述的前馈内核。 它还用到了向量运算。 区别在于前馈推算中以下一层的梯度向量替代前一层的输出值,并采用不同的权重矩阵。 而且,代替计算激活函数,结果合计是与激活函数导数的乘积。 内核代码给出如下。 

__kernel void CaclHiddenGradient(__global double *matrix_w,
                              __global double *matrix_g,
                              __global double *matrix_o,
                              __global double *matrix_ig,
                              int outputs, int activation)
  {
   int i=get_global_id(0);
   double sum=0;
   double out=matrix_o[i];
   double4 grad, weight;
   int shift=(outputs+1)*i;
   for(int k=0;k<outputs;k+=4)
     {
      switch(outputs-k)
        {
         case 0:
           grad=(double4)(1,0,0,0);
           weight=(double4)(matrix_w[shift+k],0,0,0);
           break;
         case 1:
           grad=(double4)(matrix_g[k],1,0,0);
           weight=(double4)(matrix_w[shift+k],matrix_w[shift+k+1],0,0);
           break;
         case 2:
           grad=(double4)(matrix_g[k],matrix_g[k+1],1,0);
           weight=(double4)(matrix_w[shift+k],matrix_w[shift+k+1],matrix_w[shift+k+2],0);
           break;
         case 3:
           grad=(double4)(matrix_g[k],matrix_g[k+1],matrix_g[k+2],1);
           weight=(double4)(matrix_w[shift+k],matrix_w[shift+k+1],matrix_w[shift+k+2],matrix_w[shift+k+3]);
           break;
         default:
           grad=(double4)(matrix_g[k],matrix_g[k+1],matrix_g[k+2],matrix_g[k+3]);
           weight=(double4)(matrix_w[shift+k],matrix_w[shift+k+1],matrix_w[shift+k+2],matrix_w[shift+k+3]);
           break;
        }
      sum+=dot(grad,weight);
     }
   switch(activation)
     {
      case 0:
        sum=clamp(sum+out,-1.0,1.0);
        sum=(sum-out)*(1+out)*(1-(out==1 ? 0.99 : out));
        break;
      case 1:
        sum=clamp(sum+out,0.0,1.0);
        sum=(sum-out)*(out==0 ? 0.01 : out)*(1-(out==1 ? 0.99 : out));
        break;
     }
   matrix_ig[i]=sum;
  }

3.3. 更新权重。

我们创建另一个更新权重的内核- UpdateWeights。 更新每个独立权重的过程不依赖于某个神经元之内及来自外部神经元的权重。 这允许发送批量任务,同时并行计算一层中所有神经元的所有权重。 在这种情况下,我们在线程的二维空间中运行一个内核:第一维表示神经元的序列号,第二维表示神经元内的连接数。 以下代码显示的是内核代码的前两行,其中它以二维接收线程 ID。  

__kernel void UpdateWeights(__global double *matrix_w,
                                __global double *matrix_g,
                                __global double *matrix_i,
                                __global double *matrix_dw,
                                int inputs, double learning_rates, double momentum)
  {
   int i=get_global_id(0);
   int j=get_global_id(1);
   int wi=i*(inputs+1)+j; 
   double delta=learning_rates*matrix_g[i]*(j<inputs ? matrix_i[j] : 1) + momentum*matrix_dw[wi];
   matrix_dw[wi]=delta;
   matrix_w[wi]+=delta;
  };

接下来,确定权重数组中已更新权重的偏移量,计算其增量(变化),然后将结果值添加到增量数组之中,并将其添加到当前权重里。

所有内核都放在单独的文件 NeuroNet.cl 之中,该文件将作为资源连接到主程序。

#resource "NeuroNet.cl" as string cl_program

3.4. 创建主程序类。

创建内核之后,我们返回 MQL5,并开始操控主程序代码。 主程序和内核之间的数据通过一维数组作为缓冲区进行交换(这在[文章第五部分]里的解释)。 为了在主程序端规划此类缓冲区,我们来创建 CBufferDouble 类。 该类包含指向操控 OpenCL的类对象引用,以及当用 OpenCL 创建时接收的缓冲区索引。 

class CBufferDouble     :  public CArrayDouble
  {
protected:
   COpenCLMy         *OpenCL;
   int               m_myIndex;           
public:
                     CBufferDouble(void);
                    ~CBufferDouble(void);
//---
   virtual bool      BufferInit(uint count, double value);
   virtual bool      BufferCreate(COpenCLMy *opencl);
   virtual bool      BufferFree(void);
   virtual bool      BufferRead(void);
   virtual bool      BufferWrite(void);
   virtual int       GetData(double &values[]);
   virtual int       GetData(CArrayDouble *values);
   virtual int       GetIndex(void)                        {  return m_myIndex;      }
//---
   virtual int       Type(void)                      const { return defBufferDouble; }
  };

请注意,一旦创建了 OpenCL 缓冲区,其句柄将被返回。 该句柄被存储在 COpenCL 类的 m_buffers 数组当中。 在 m_myIndex 变量中,仅存储指定数组中的索引。 这是因为整个 COpenCL 类操作都会用到指定的此类索引,而非内核或缓冲区句柄。 还应注意,COpenCL 类原装操作算法需要初始指定所用缓冲区编号,进而按指定索引创建缓冲区。 在我们的例子中,我们将在创建神经层时动态添加缓冲区。 这就是为何 COpenCLMy 类是从 COpenCL 派生而来的。 该类仅包含一个附加方法。 您可以在附件中找到其代码。

CBufferDouble 类中创建了以下操控缓冲区的方法:

  • BufferInit — 按照指定值初始化缓冲区数组
  • BufferCreate  — 在 OpenCL 中创建一个缓冲区
  • BufferFree  — 在 OpenCL 中删除一个缓冲区
  • BufferRead  — 从 OpenCL 缓冲区读取数据到数组
  • BufferWrite  — 将数组中的数据写入 OpenCL 缓冲区
  • GetData  — 根据请求获取数组数据。 它以两种变体实现,可将数据返回到数组和 CArrayDouble 类。
  • GetIndex  — 返回缓冲区索引

所有方法的体系结构都很简单,它们的代码占用 1-2 行。 下面的附件中提供了所有方法的完整代码。

3.5. 创建操控 OpenCL 的神经元基类。

我们来继续研究 CNeuronBaseOCL 类,它包括主要的附加项和操作算法。 很难将创建的对象命名为神经元,因为它包含了整个完全连接神经层的工作。 对于早前研究的卷积层和 LSTM 模块也是如此。 但这种方式可保留以前构建的神经网络体系结构。

类 CNeuronBaseOCL 包含一个指向 COpenCLMy 类对象的指针,和四个缓冲区:输出值、权重系数矩阵、最后的权重增量和误差梯度。

class CNeuronBaseOCL    :  public CObject
  {
protected:
   COpenCLMy         *OpenCL;
   CBufferDouble     *Output;
   CBufferDouble     *Weights;
   CBufferDouble     *DeltaWeights;
   CBufferDouble     *Gradient;

同样,声明学习和动量系数,层中神经元的序数,以及激活函数类型。

   const double      eta;
   const double      alpha;
//---
   int               m_myIndex;
   ENUM_ACTIVATION   activation;

在类的受保护模块中再添加三个方法:前馈、隐藏梯度计算,和更新权重矩阵。

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

在公开部分中,声明类构造函数,和析构函数,神经元初始化方法,和指定激活函数的方法。

public:
                     CNeuronBaseOCL(void);
                    ~CNeuronBaseOCL(void);
   virtual bool      Init(uint numOutputs, uint myIndex, COpenCLMy *open_cl, uint numNeurons);
   virtual void      SetActivationFunction(ENUM_ACTIVATION value) {  activation=value; }

为了从神经元外部访问数据,声明获取缓冲区索引的方法(在调用内核时会用到它们),和从缓冲区以数组形式接收当前信息的方法。 此外,添加轮询神经元数量和激活函数的方法。

   virtual int       getOutputIndex(void)          {  return Output.GetIndex();        }
   virtual int       getGradientIndex(void)        {  return Gradient.GetIndex();      }
   virtual int       getWeightsIndex(void)         {  return Weights.GetIndex();       }
   virtual int       getDeltaWeightsIndex(void)    {  return DeltaWeights.GetIndex();  }
//---
   virtual int       getOutputVal(double &values[])   {  return Output.GetData(values);      }
   virtual int       getOutputVal(CArrayDouble *values)   {  return Output.GetData(values);  }
   virtual int       getGradient(double &values[])    {  return Gradient.GetData(values);    }
   virtual int       getWeights(double &values[])     {  return Weights.GetData(values);     }
   virtual int       Neurons(void)                    {  return Output.Total();              }
   virtual ENUM_ACTIVATION Activation(void)           {  return activation;                  }

当然,还可以创建前馈传递、误差梯度计算和更新权重矩阵的调度方法。 不要忘记重写保存和读取数据的虚拟函数。 

   virtual bool      feedForward(CObject *SourceObject);
   virtual bool      calcHiddenGradients(CObject *TargetObject);
   virtual bool      calcOutputGradients(CArrayDouble *Target);
   virtual bool      updateInputWeights(CObject *SourceObject);
//---
   virtual bool      Save(int const file_handle);
   virtual bool      Load(int const file_handle);
   //---
   virtual int       Type(void)        const                      {  return defNeuronBaseOCL;                  }
  };

我们来研究构造方法的算法。 类的构造函数和析构函数非常简单。 附件中提供了它们的代码。 先看一下类的初始化函数。 该方法从参数中接收下一层神经元的数量,神经元的序数,指向 COpenCLMy 类对象的指针,以及要创建的神经元的数量。

请注意,该方法从参数中接收指向 COpenCLMy 类对象的指针,但不会实例化该类内部的对象。 这样可以确保在 EA 操作期间仅用 COpenCLMy 对象的一个实例。 所有内核和数据缓冲区将在一个对象中创建,因此我们无需为了在神经网络各层之间传递数据而浪费时间。 它们均直接访问同一数据缓冲区。

在方法开始时,检查指向 COpenCLMy 类对象的指针的有效性,并确保至少已创建了一个神经元。 接下来,创建缓冲区对象的实例,以初始值初始化数组,并在 OpenCL 中创建缓冲区。 `Output' 缓冲区的大小等于所要创建的神经元数量,且梯度缓冲区的大小应多 1 个元素。 权重矩阵及其增量缓冲区的大小等于梯度缓冲区大小与下一层神经元数量的乘积。 由于输出层的乘积将为 “0”,因此不会为此层创建缓冲区。

bool CNeuronBaseOCL::Init(uint numOutputs,uint myIndex,COpenCLMy *open_cl,uint numNeurons)
  {
   if(CheckPointer(open_cl)==POINTER_INVALID || numNeurons<=0)
      return false;
   OpenCL=open_cl;
//---
   if(CheckPointer(Output)==POINTER_INVALID)
     {
      Output=new CBufferDouble();
      if(CheckPointer(Output)==POINTER_INVALID)
         return false;
     }
   if(!Output.BufferInit(numNeurons,1.0))
      return false;
   if(!Output.BufferCreate(OpenCL))
      return false;
//---
   if(CheckPointer(Gradient)==POINTER_INVALID)
     {
      Gradient=new CBufferDouble();
      if(CheckPointer(Gradient)==POINTER_INVALID)
         return false;
     }
   if(!Gradient.BufferInit(numNeurons+1,0.0))
      return false;
   if(!Gradient.BufferCreate(OpenCL))
      return false;
//---
   if(numOutputs>0)
     {
      if(CheckPointer(Weights)==POINTER_INVALID)
        {
         Weights=new CBufferDouble();
         if(CheckPointer(Weights)==POINTER_INVALID)
            return false;
        }
      int count=(int)((numNeurons+1)*numOutputs);
      if(!Weights.Reserve(count))
         return false;
      for(int i=0;i<count;i++)
        {
         double weigh=(MathRand()+1)/32768.0-0.5;
         if(weigh==0)
            weigh=0.001;
         if(!Weights.Add(weigh))
            return false;
        }
      if(!Weights.BufferCreate(OpenCL))
         return false;
   //---
      if(CheckPointer(DeltaWeights)==POINTER_INVALID)
        {
         DeltaWeights=new CBufferDouble();
         if(CheckPointer(DeltaWeights)==POINTER_INVALID)
            return false;
        }
      if(!DeltaWeights.BufferInit(count,0))
         return false;
      if(!DeltaWeights.BufferCreate(OpenCL))
         return false;
     }
//---
   return true;
  }

feedForward 调度程序方法类似于 CNeuronBase 类里同名的方法。 现在,此处仅指定一个种类的神经元,但以后可以添加更多的种类。

bool CNeuronBaseOCL::feedForward(CObject *SourceObject)
  {
   if(CheckPointer(SourceObject)==POINTER_INVALID)
      return false;
//---
   CNeuronBaseOCL *temp=NULL;
   switch(SourceObject.Type())
     {
      case defNeuronBaseOCL:
        temp=SourceObject;
        return feedForward(temp);
        break;
     }
//---
   return false;
  }

feedForward(CNeuronBaseOCL *NeuronOCL) 方法中直接调用 OpenCL 内核。 在方法伊始,检查指向 COpenCLMy 类对象的指针和接收到的指向神经网络前一层的指针有效性。

bool CNeuronBaseOCL::feedForward(CNeuronBaseOCL *NeuronOCL)
  {
   if(CheckPointer(OpenCL)==POINTER_INVALID || CheckPointer(NeuronOCL)==POINTER_INVALID)
      return false;

指示线程空间的一维性,并设置所需线程的数量等于神经元的数量。

   uint global_work_offset[1]={0};
   uint global_work_size[1];
   global_work_size[0]=Output.Total();

接下来,设置指向所用数据缓冲区的指针,和内核操作参数。

   OpenCL.SetArgumentBuffer(def_k_FeedForward,def_k_ff_matrix_w,NeuronOCL.getWeightsIndex());
   OpenCL.SetArgumentBuffer(def_k_FeedForward,def_k_ff_matrix_i,NeuronOCL.getOutputIndex());
   OpenCL.SetArgumentBuffer(def_k_FeedForward,def_k_ff_matrix_o,Output.GetIndex());
   OpenCL.SetArgument(def_k_FeedForward,def_k_ff_inputs,NeuronOCL.Neurons());
   OpenCL.SetArgument(def_k_FeedForward,def_k_ff_activation,(int)activation);

之后,调用内核。

   if(!OpenCL.Execute(def_k_FeedForward,1,global_work_offset,global_work_size))
      return false;

我打算于此结束,但在测试过程中遇到了一个问题: COpenCL::Execute 方法并未启动内核,而只是将其排队。 执行本身则是在尝试读取内核结果时才会发生。 这就是为什么在退出该方法之前必须将处理结果加载到数组中的原因。

   Output.BufferRead();
//---
   return true;
  }

启动其他内核的方法与上述算法相似。 附件中提供了所有方法和类的完整代码。

3.6. CNet 类中的附加。

一旦创建了所有必需的类,我们需针对主神经网络的 CNet 类进行一些调整。

在类构造函数中,我们需要为 COpenCLMy 类实例添加创建和初始化。 不要忘记删除析构函数中的类对象。 

   opencl=new COpenCLMy();
   if(CheckPointer(opencl)!=POINTER_INVALID && !opencl.Initialize(cl_program,true))
      delete opencl;

此外,在构造函数中往层里添加神经元的代码模块中,加入代码来创建和初始化早前创建的 CNeuronBaseOCL 类对象。

      if(CheckPointer(opencl)!=POINTER_INVALID)
        {
         CNeuronBaseOCL *neuron_ocl=NULL;
         switch(desc.type)
           {
            case defNeuron:
            case defNeuronBaseOCL:
              neuron_ocl=new CNeuronBaseOCL();
              if(CheckPointer(neuron_ocl)==POINTER_INVALID)
                {
                 delete temp;
                 return;
                }
              if(!neuron_ocl.Init(outputs,0,opencl,desc.count))
                {
                 delete temp;
                 return;
                }
              neuron_ocl.SetActivationFunction(desc.activation);
              if(!temp.Add(neuron_ocl))
                {
                 delete neuron_ocl;
                 delete temp;
                 return;
                }
              neuron_ocl=NULL;
              break;
            default:
              return;
              break;
           }
        }

进而,在构造函数中添加创建 OpenCL 内核。

   if(CheckPointer(opencl)==POINTER_INVALID)
      return;
//--- create kernels
   opencl.SetKernelsCount(4);
   opencl.KernelCreate(def_k_FeedForward,"FeedForward");
   opencl.KernelCreate(def_k_CaclOutputGradient,"CaclOutputGradient");
   opencl.KernelCreate(def_k_CaclHiddenGradient,"CaclHiddenGradient");
   opencl.KernelCreate(def_k_UpdateWeights,"UpdateWeights");

CNet::feedForward 方法中添加将源数据写入缓冲区的代码

     {
      CNeuronBaseOCL *neuron_ocl=current.At(0);
      double array[];
      int total_data=inputVals.Total();
      if(ArrayResize(array,total_data)<0)
         return false;
      for(int d=0;d<total_data;d++)
         array[d]=inputVals.At(d);
      if(!opencl.BufferWrite(neuron_ocl.getOutputIndex(),array,0,0,total_data))
         return false;
     }

还要为新创建的类 CNeuronBaseOCL 添加相应的方法调用。

   for(int l=1; l<layers.Total(); l++)
     {
      previous=current;
      current=layers.At(l);
      if(CheckPointer(current)==POINTER_INVALID)
         return false;
      //---
      if(CheckPointer(opencl)!=POINTER_INVALID)
        {
         CNeuronBaseOCL *current_ocl=current.At(0);
         if(!current_ocl.feedForward(previous.At(0)))
            return false;
         continue;
        }

对于反向传播过程,我们为其创建一个新方法 CNet::backPropOCL。 它的算法类似于第一篇文章中所述的主要方法 CNet::backProp

void CNet::backPropOCL(CArrayDouble *targetVals)
  {
   if(CheckPointer(targetVals)==POINTER_INVALID || CheckPointer(layers)==POINTER_INVALID || CheckPointer(opencl)==POINTER_INVALID)
      return;
   CLayer *currentLayer=layers.At(layers.Total()-1);
   if(CheckPointer(currentLayer)==POINTER_INVALID)
      return;
//---
   double error=0.0;
   int total=targetVals.Total();
   double result[];
   CNeuronBaseOCL *neuron=currentLayer.At(0);
   if(neuron.getOutputVal(result)<total)
      return;
   for(int n=0; n<total && !IsStopped(); n++)
     {
      double target=targetVals.At(n);
      double delta=(target>1 ? 1 : target<-1 ? -1 : target)-result[n];
      error+=delta*delta;
     }
   error/= total;
   error = sqrt(error);
   recentAverageError+=(error-recentAverageError)/recentAverageSmoothingFactor;

   if(!neuron.calcOutputGradients(targetVals))
      return;;
//--- Calc Hidden Gradients
   CObject *temp=NULL;
   total=layers.Total();
   for(int layerNum=total-2; layerNum>0; layerNum--)
     {
      CLayer *nextLayer=currentLayer;
      currentLayer=layers.At(layerNum);
      neuron=currentLayer.At(0);
      neuron.calcHiddenGradients(nextLayer.At(0));
     }
//---
   CLayer *prevLayer=layers.At(total-1);
   for(int layerNum=total-1; layerNum>0; layerNum--)
     {
      currentLayer=prevLayer;
      prevLayer=layers.At(layerNum-1);
      neuron=currentLayer.At(0);
      neuron.updateInputWeights(prevLayer.At(0));
     }
  }

针对 getResult 方法略微进行了一些修改。

   if(CheckPointer(opencl)!=POINTER_INVALID && output.At(0).Type()==defNeuronBaseOCL)
     {
      CNeuronBaseOCL *temp=output.At(0);
      temp.getOutputVal(resultVals);
      return;
     }

附件中提供了所有方法和函数的完整代码。

4. 测试

采用与之前测试相同的条件,测试所创建类的操作。 已创建 Fractal_OCL EA 用于测试,它与先前创建的 Fractal_2 完全相同。 在 H1 时间帧,EURUSD 货币对上测试了神经网络的训练。 将 20 根烛条的数据输入到神经网络。 训练时采用最近两年的数据。 实验在支持 OpenCL 的 'Intel(R) Core(TM)2 Duo CPU T5750 @ 2.00GHz' 设备上运行。

在 5 小时 27 分钟的测试中,利用 OpenCL 技术的 EA 共执行了 75 个训练时期。 对于 12405 根烛条的区间,这平均需要 4 分 22 秒。 未利用 OpenCL 技术的同一智能交易系统,在同一台笔记本电脑上的相同神经网络体系结构下,每个时期平均要花费 40 分钟 48 秒。 如此,利用 OpenCL 可以令学习过程快 9.35 倍。


结束语

本文演示了利用 OpenCL 技术在神经网络中规划多线程计算的可能性。 测试表明,在同一 CPU 上,性能几乎提高了 10 倍。 期望利用 GPU 进一步提高算法性能 - 在这种情况下,将计算转移到兼容的 GPU 不需要修改智能交易系统代码。

总体而言,结果证明该方向的进一步发展具有良好的前景。


链接

  1. 神经网络变得轻松
  2. 神经网络变得轻松(第二部分):网络训练和测试
  3. 神经网络变得轻松(第三部分):卷积网络
  4. 神经网络变得轻松(第四部分):循环网络
  5. OpenCL: 通往并行世界的桥梁
  6. OpenCL: 从初学到精通编程

本文中用到的程序

# 名称 类型 说明
1 Fractal_OCL.mq5  智能交易系统 利用 OpenCL 技术的含有分类神经网络(输出层中有 3 个神经元)的智能交易系统
2 NeuroNet.mqh 类库 用于创建神经网络的类库
3 NeuroNet.cl 代码库 OpenCL 程序代码库


本文译自 MetaQuotes Software Corp. 撰写的俄文原文
原文地址: https://www.mql5.com/ru/articles/8435

附加的文件 |
MQL5.zip (396.86 KB)
网格和马丁格尔:它们是什么?如何使用它们? 网格和马丁格尔:它们是什么?如何使用它们?

在本文中,我将试图详细解释什么是网格和马丁格尔,以及它们的共同点。此外,我将试着分析这些策略到底有多可行。这篇文章同时包含了数学和实践部分。

模式搜索的暴力方法 模式搜索的暴力方法

在本文中,我们将搜索市场模式,根据确定的模式创建 EA 交易,并检查这些模式,如果它们保持有效的话,保持有效的时间有多少。

DoEasy 函数库中的时间序列(第五十三部分):抽象基准指标类 DoEasy 函数库中的时间序列(第五十三部分):抽象基准指标类

本文研究创建一个抽象指标,其将进一步用作创建函数库标准指标和自定义指标对象的基类。

开发和分析交易系统的最佳方法 开发和分析交易系统的最佳方法

在这篇文章中,我将展示在选择一个系统或信号来投资你的资金时所使用的标准,以及描述开发交易系统的最佳方法,并强调这个问题在外汇交易中的重要性。