神经网络变得轻松(第三部分):卷积网络

Dmitriy Gizlyk | 11 一月, 2021

内容


概述

作为神经网络主题的延续,我建议研究卷积神经网络。 这些神经网络通常会应用在识别照片和视频图像中的对象有关问题。 卷积神经网络据信可以抵抗缩放、角度变换和其他空间性图像失真。 它们的体系结构在场景中的任何地方识别对象时成功率均等。 当应用于交易时,我打算用卷积神经网络来改善对价格图表上交易形态的识别。

1. 卷积神经网络的显著特征

与完全连接的感知器相比,卷积网络拥有两种新的层类型:卷积(滤波器)和子抽样。 这些层交替排列,目的是选择主要成分,并剔除源数据中的噪音,同时减小数据维度(体量)。 然后,将这些数据输入到完全连接的感知器中,以便制定决策。 下图以图形方式展示了卷积神经网络的结构。 根据任务,我们可以依次使用若干组交替的卷积和子抽样层。

卷积神经网络的图形表示

1.1. 卷积层

卷积层负责识别源数据数组中的对象。 该层顺序执行原始数据的数学卷积,并用小型形态(过滤器)充当卷积内核。

卷积是针对两个函数(fg)的功能分析操作,产生与互相关函数 f(x) g(-x) 对应的第三个函数。 卷积运算可解释为一个函数与另一个逆向和偏移函数副本的“相似性”(Wikipedia )。

换言之,卷积层在整个原始样本中搜索形态元素。 在每次迭代中,模板都以给定步长沿初始数据数组移动,其大小可以从 “1” 到形态大小。 如果偏移步长小于形态大小,则将这种卷积称为重叠。

卷积运算将生成一个特征数组,这些特征将在每次迭代时示意原始数据与所需形态的“相似性”。 激活函数用于规范化数据。 所得数组大小将小于原始数据数组。 这种数组的数量等于过滤器的数量。

重要的一点是,在设计神经网络时不会指定形态,而是在学习过程中选择它们。

1.2. 子抽样层

下一个子抽样层用于减小特征数组的尺寸,并过滤噪音。 使用此迭代的前提是,原始数据和形态之间存在相似性是主要的,而原始数据数组中特征值的确切坐标并不那么重要。 这为缩放问题提供了解决方案,因为它允许所需对象之间的距离有所波动。

在这一阶段,在给定的“窗口”内保持最大值或平均值,数据得以压缩。 因此,对于每个数据“窗口”仅保存一个值。 这些操作会迭代执行,且在每次新迭代时,窗口都会按给定步长偏移。 数据压缩是针依据每个特征数组分别执行的。

最常用的是带有窗口且步长等于 2 的子抽样层 - 这能够将特征数组的尺寸减半。 然而,实际上可以用更大的窗口,而压缩迭代可以重叠(当步长小于窗口大小时)执行,或不重叠。

子抽样层输出的特征数组尺寸较小。 

取决于问题的复杂性,可以在子抽样层之后使用一或多组卷积和子抽样层。 它们的构造原理和功能与上述层相对应。 在一般情况下,经过一组或若干组卷积 + 压缩之后,针对所有过滤器获取的的特征数组被汇集到单一的矢量中,并馈送至神经网络的多层感知器中,从而制定决策(多层感知器的构造在本系列的第一部分里有详述)。


2. 在卷积层中训练神经元的原理

卷积神经网络通过反向传播方法进行训练,该方法已在之前的文章中有所讨论。 这是受监督学习方法之一。 它由来自神经元输出层的降序误差梯度组成,经由隐藏层,直至神经元的输入层,并带有逆梯度朝向的权重校正。

第一篇文章中曾介绍了多层感知器训练,故在此不做解释。 我们来研究训练子抽样和卷积层神经元。

在子抽样层中,为每个特征数组元素计算误差梯度,类似于完全连接的感知器中神经元的梯度。 传递梯度至前一层的算法取决于所应用的压缩操作。 如果仅用到最大值,则将整个梯度以最大值馈送给神经元(在压缩窗口内为所有其他元素设置零梯度)。 如果在窗口内采用平均操作,则梯度均匀地分布于窗口内的所有元素。

压缩操作不会用权重,这就是为什么在学习过程中没有进行任何调整的原因。

训练卷积层的神经元时,计算有些复杂。 误差梯度会针对特征数组的每个元素进行计算,并被馈送到前一层的相应神经元。 卷积层训练过程是基于卷积和逆卷积运算。

为了将误差梯度从子抽样层传递到卷积层,首先把从子抽样层获得的误差梯度数组的边缘用零元素填充,然后将得到的数组与旋转 180° 后的卷积核心进行卷积。 输出是一个误差梯度数组,其维度与输入数据数组相等,其梯度索引与卷积层前导神经元的索引相对应。

将输入值的矩阵与该层旋转 180° 后的误差梯度矩阵进行卷积,可得到权重增量。 这将输出一个增量数组,其尺寸与卷积内核相等。 所得的增量需要针对卷积层激活函数和学习因子的导数进行调整。 之后,卷积内核的权重将按照调整后的增量值来变化。

这听起来很难理解。 我将在下面的代码详细分析中尝试澄清一些注释。


3. 建立卷积神经网络

卷积神经网络将由三种类型的神经层(卷积层,子抽样层和完全连接层)组成,具有独特的神经元类,意即前行和回退等不同功能。 与此同时,我们需要将所有神经元合并为一个网络,并把处理神经元相对应的数据处理方法的调用进行组织管理。 我认为组织管理这些过程的最简单方法是利用类继承和函数虚拟化。

首先,我们建立类的继承结构。

神经元类的继承结构

3.1. 神经元的基类。

第一篇文章中,我们创建了 CLayer 层类作为 CArrayObj 的后代,CArrayObj 是动态数组类,用于存储指向 CObject 类对象的指针。 因此,所有神经元必须继承自该类。 基于 CObject 类创建了 CNeuronBase 类。 在类主体中,声明的变量通用于所有神经元类型,并为主要方法创建模板。 将该类的所有方法声明为虚拟的,以便将来实现重新定义。 

class CNeuronBase    :  public CObject
  {
protected:
   double            eta;
   double            alpha;
   double            outputVal;
   uint              m_myIndex;
   double            gradient;
   CArrayCon        *Connections;
//--- 
   virtual bool      feedForward(CLayer *prevLayer)               {  return false;     }
   virtual bool      calcHiddenGradients( CLayer *&nextLayer)     {  return false;     }
   virtual bool      updateInputWeights(CLayer *&prevLayer)       {  return false;     }
   virtual double    activationFunction(double x)                 {  return 1.0;       }
   virtual double    activationFunctionDerivative(double x)       {  return 1.0;       }
   virtual CLayer    *getOutputLayer(void)                        {  return NULL;      }
public:
                     CNeuronBase(void);
                    ~CNeuronBase(void);
   virtual bool      Init(uint numOutputs, uint myIndex);
//---
   virtual void      setOutputVal(double val)                     {  outputVal=val;    }
   virtual double    getOutputVal()                               {  return outputVal; }
   virtual void      setGradient(double val)                      {  gradient=val;     }
   virtual double    getGradient()                                {  return gradient;  }
//---
   virtual bool      feedForward(CObject *&SourceObject);
   virtual bool      calcHiddenGradients( CObject *&TargetObject);
   virtual bool      updateInputWeights(CObject *&SourceObject);
//---
   virtual bool      Save( int const file_handle);
   virtual bool      Load( int const file_handle)                  {  return(Connections.Load(file_handle)); }
//---
   virtual int       Type(void)        const                       {  return defNeuronBase;                  }
  };

变量名和方法名与前述相同。 我们来研究方法 feedForward(CObject *&SourceObject), сalcHiddenGradients(CObject *&TargetObject)updateInputWeights(CObject *&SourceObject), 其内调度全连接操控和卷积层执行。

3.1.1. 前馈。

在前行推算过程中调用 feedForward(CObject *&SourceObject) 方法,以便计算结果神经元值。 在前行推算过程中,完全连接的层中每个神经元都将获取前一层所有神经元的值,且必须接收整个前一层作为输入。 在卷积和子抽样层中,仅与此过滤器有关的数据部分被馈送到神经元。 在所研究的方法中,依据在参数中指定类的类型来选择算法。

首先,检查在方法参数中得到的对象指针的有效性。

bool CNeuronBase::feedForward(CObject *&SourceObject)
  {
   bool result=false;
//---
   if(CheckPointer(SourceObject)==POINTER_INVALID)
      return result;

鉴于无法在所选操作数里声明类实例,因此我们需要预先准备模板。

   CLayer *temp_l;
   CNeuronProof *temp_n;

接下来,在所选操作数中,检查参数中接收到的对象类型。 如果收到的是指向神经元层的指针,那么前一层是完全连接的,因此,我们需要调用一个方法来操控完全连接层(在第一篇文章中曾有详述)。 如果它是卷积或子抽样层的神经元,那么首先我们得到该过滤器的输出神经元层,然后利用处理完全连接层的方法,故我们应该在其中输入当前过滤器的神经元层,之后处理结果必须保存在 result 变量里(有关卷积和子抽样层中神经元结构的更多详细信息,将在下面提供)。 操作完毕后,退出该方法,并传递操作结果。

   switch(SourceObject.Type())
     {
      case defLayer:
        temp_l=SourceObject;
        result=feedForward(temp_l);
        break;
      case defNeuronConv:
      case defNeuronProof:
        temp_n=SourceObject;
        result=feedForward(temp_n.getOutputLayer());
        break;
     }
//---
   return result;
  }

3.1.2. 误差梯度计算。

与前行推算类似,创建了一个调度程序来调用该函数来计算神经网络隐藏层的误差梯度 - сalcHiddenGradients(CObject*&TargetObject)。 该方法的逻辑和结构与上述类似。 首先,检查所接收指针的有效性。 接下来,声明存储指向相应对象指针的变量。 然后,根据收到的对象类型在选择函数中选择相应的方法。 如果在参数中传递指向卷积或子抽样层的元素指针,则会发生差异。 通过此类神经元计算出的误差梯度是有区别的,并且不适用于前一层的所有神经元,而仅适用于采样窗口内的神经元。 这就是为什么在 calcInputGradients 方法里将梯度计算转移到这些神经元的原因。 同样,用于逐层或特定神经元计算的方法也有所不同。 所以,取决于所调用对象的类型来调用所需方法。  

bool CNeuronBase::calcHiddenGradients(CObject *&TargetObject)
  {
   bool result=false;
//---
   if(CheckPointer(TargetObject)==POINTER_INVALID)
      return result;
//---
   CLayer *temp_l;
   CNeuronProof *temp_n;
   switch(TargetObject.Type())
     {
      case defLayer:
        temp_l=TargetObject;
        result=calcHiddenGradients(temp_l);
        break;
      case defNeuronConv:
      case defNeuronProof:
        switch(Type())
          {
           case defNeuron:
             temp_n=TargetObject;
             result=temp_n.calcInputGradients(GetPointer(this),m_myIndex);
             break;
           default:
             temp_n=GetPointer(this);
             temp_l=temp_n.getOutputLayer();
             temp_n=TargetObject;
             result=temp_n.calcInputGradients(temp_l);
             break;
          }
        break;
     }
//---
   return result;
  }

基于上述原理,updateInputWeights(CObject *&SourceObject) 调度器更新所有权重。 完整代码可在附件中找到。

3.2. 子抽样层元素。

子抽样层的主要构建模块是 CNeuronProof 类,该类继承自先前所述的 CNeuronBase 基类。 将为子抽样层中的每个过滤器创建一个此类的实例。 为此,引入了附加变量(iWindow 和 iStep)来存储压缩窗口尺寸,和偏移步长。 我们还添加了一个神经元内层,用于存储特征数组,误差梯度,并在必要时将特征权重传递到完全连接感知器。 另外,加入一个方法,按需接收指向神经元内层的指针。 

class CNeuronProof : public CNeuronBase
  {
protected:
   CLayer            *OutputLayer;
   int               iWindow;
   int               iStep;
   
   virtual bool      feedForward(CLayer *prevLayer);
   virtual bool      calcHiddenGradients( CLayer *&nextLayer);
   
public:
                     CNeuronProof(void){};
                    ~CNeuronProof(void);
   virtual bool      Init(uint numOutputs,uint myIndex,int window, int step, int output_count);
//---
   virtual CLayer   *getOutputLayer(void)  { return OutputLayer;  }
   virtual bool      calcInputGradients( CLayer *prevLayer) ;
   virtual bool      calcInputGradients( CNeuronBase *prevNeuron, uint index) ;
   //--- methods for working with files
   virtual bool      Save( int const file_handle)                         { return(CNeuronBase::Save(file_handle) && OutputLayer.Save(file_handle));   }
   virtual bool      Load( int const file_handle)                         { return(CNeuronBase::Load(file_handle) && OutputLayer.Load(file_handle));   }
   virtual int       Type(void)   const   {  return defNeuronProof;   }
  };

不要忘记为基类中声明的虚函数重新定义逻辑。

3.2.1. 前馈。

feedForward 方法用于滤除噪声,并减小特征数组的尺寸。 在所述的解决方案中,算术平均函数用于压缩数据。 我们来更详尽地研究该方法的代码。 在该方法伊始,检查指向神经元前一层指针的相关性。

bool CNeuronProof::feedForward(CLayer *prevLayer)
  {
   if(CheckPointer(prevLayer)==POINTER_INVALID)
      return false;

然后,按步长循环遍历参数中给定层的所有神经元。

   int total=prevLayer.Total()-iWindow+1;
   CNeuron *temp;
   for(int i=0;(i<=total && result);i+=iStep)
     {

在循环主体中,创建一个嵌套循环,以便计算指定压缩窗口内前一层神经元的输出值之和。

      double sum=0;
      for(int j=0;j<iWindow;j++)
        {
         temp=prevLayer.At(i+j);
         if(CheckPointer(temp)==POINTER_INVALID)
            continue;
         sum+=temp.getOutputVal();
        }

计算总和之后,利用相应的内层神经元存储结果数据,并将获得的总和与窗口尺寸的比率写入其结果值。 该比率将是当前压缩窗口的算术平均值。

      temp=OutputLayer.At(i/iStep);
      if(CheckPointer(temp)==POINTER_INVALID)
         return false;
      temp.setOutputVal(sum/iWindow);
     }
//---
   return true;
  }

在遍历所有神经元后,该方法完毕。

3.2.2. 误差梯度计算。

在该类中创建了两个方法来计算误差梯度: calcHiddenGradients calcInputGradients。 第一个类收集有关后续层的误差梯度数据,并计算当前层元素的梯度。 第二个类利用第一个方法中获得的数据,意即在前层元素之间分布的误差。

再次,在 calcHiddenGradients 方法的开头检查得到的指针有效性。 此外,检查神经元内层的状态。

bool CNeuronProof::calcHiddenGradients( CLayer *&nextLayer)
  {
   if(CheckPointer(nextLayer)==POINTER_INVALID || CheckPointer(OutputLayer)==POINTER_INVALID || OutputLayer.Total()<=0)
      return false;

然后,遍历所有内层神经元,并调用一个方法计算误差梯度。

   gradient=0;
   int total=OutputLayer.Total();
   CNeuron *temp;
   for(int i=0;i<total;i++)
     {
      temp=OutputLayer.At(i);
      if(CheckPointer(temp)==POINTER_INVALID)
         return false;
      temp.setGradient(temp.sumDOW(nextLayer));
     }
//---
   return true;
  }

请注意,如果后面紧接一个完全连接的神经元层,则该方法仍将正确运行。 如果紧随其后是卷积或子抽样层,则调用下一层神经元的 calcInputGradients 方法。

calcInputGradients 方法在参数中接收指向前一层的指针。 不要忘记在方法开始时检查指针的有效性。

bool CNeuronProof::calcInputGradients(CLayer *prevLayer) 
  {
   if(CheckPointer(prevLayer)==POINTER_INVALID || CheckPointer(OutputLayer)==POINTER_INVALID)
      return false;

然后检查在层参数中获得的第一个元素的类型。 如果所得引用指向子抽样或卷积层,则请求一个与过滤器相对应的神经元内层的引用。

   if(prevLayer.At(0).Type()!=defNeuron)
     {
      CNeuronProof *temp=prevLayer.At(m_myIndex);
      if(CheckPointer(temp)==POINTER_INVALID)
         return false;
      prevLayer=temp.getOutputLayer();
      if(CheckPointer(prevLayer)==POINTER_INVALID)
         return false;
     }

接下来,遍历上一层的所有神经元,检查对已处理神经元的引用的有效性。

   CNeuronBase *prevNeuron, *outputNeuron;
   int total=prevLayer.Total();
   for(int i=0;i<total;i++)
     {
      prevNeuron=prevLayer.At(i);
      if(CheckPointer(prevNeuron)==POINTER_INVALID)
         continue;

判断哪些内层神经元受到所处理神经元的影响。

      double prev_gradient=0;
      int start=i-iWindow+iStep;
      start=(start-start%iStep)/iStep;
      double stop=(i-i%iStep)/iStep+1;

在一个循环中,计算经处理神经元的误差梯度,并保存结果。 该方法在处理前一层所有神经元之后便结束。

      for(int out=(int)fmax(0,start);out<(int)fmin(OutputLayer.Total(),stop);out++)
        {
         outputNeuron=OutputLayer.At(out);
         if(CheckPointer(outputNeuron)==POINTER_INVALID)
            continue;
         prev_gradient+=outputNeuron.getGradient()/iWindow;
        }
      prevNeuron.setGradient(prev_gradient);
     }
//---
   return true;
  }

计算单独神经元梯度的同名方法也具有相似的结构。 区别在于排除了外部循环迭代神经元。 取而代之,按索引调用神经元。

鉴于在子抽样层中未使用权重,因此可以省略权重更新方法。 若您希望保留神经元类的结构,您可创建一个空方法,且当真正调用时再创建。 

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

3.3. 卷积层元素。

卷积层将利用 CNeuronConv 类对象构建,它们继承自 CNeuronProof 类。 我已选择了参数 ReLU 作为该类神经元的激活函数。 该函数比之在完全连接感知器神经元中使用的双曲正切更容易计算。 我们引入一个附加变量 param ,用于计算函数。

class CNeuronConv  :  public CNeuronProof
  {
protected:
   double            param;   //PReLU param
   virtual bool      feedForward(CLayer *prevLayer);
   virtual bool      calcHiddenGradients(CLayer *&nextLayer);
   virtual double    activationFunction(double x);
   virtual bool      updateInputWeights(CLayer *&prevLayer);
public:
                     CNeuronConv() :   param(0.01) { };
                    ~CNeuronConv(void)             { };
//---
   virtual bool      calcInputGradients(CLayer *prevLayer) ;
   virtual bool      calcInputGradients(CNeuronBase *prevNeuron, uint index) ;
   virtual double    activationFunctionDerivative(double x);
   virtual int       Type(void)   const   {  return defNeuronConv;   }
  };

前行和回退推算方法基于类似于 CNeuronProof 类的算法。 区别在于采用的激活函数和权重系数。 因此,我不会对其详细讲述。 我们研究权重调整方法 updateInputWeights

该方法将接收指向神经元前一层的指针。 再次,我们在方法伊始检查指针的有效性,和内层状态。

bool CNeuronConv::updateInputWeights(CLayer *&prevLayer)
  {
   if(CheckPointer(prevLayer)==POINTER_INVALID || CheckPointer(OutputLayer)==POINTER_INVALID)
      return false;

接下来,创建一个循环,遍历所有权重。 不要忘记检查收到的对象指针的有效性。

   CConnection *con;
   for(int n=0; n<iWindow && !IsStopped(); n++)
     {
      con=Connections.At(n);
      if(CheckPointer(con)==POINTER_INVALID)
         continue;

之后,计算输入数据数组与内层误差梯度数组旋转 180° 的卷积。 按照以下规划,循环遍历内层的所有元素,然后乘以输入数据数组元素:

然后,求所得乘积的总和。

      double delta=0;
      int total_i=OutputLayer.Total();
      CNeuron *prev, *out;
      for(int i=0;i<total_i;i++)
        {
         prev=prevLayer.At(n*iStep+i);
         out=OutputLayer.At(total_i-i-1);
         if(CheckPointer(prev)==POINTER_INVALID || CheckPointer(out)==POINTER_INVALID)
            continue;
         delta+=prev.getOutputVal()*out.getGradient();
        }

计算得出的乘积之和,用作调整重量的基础。 调整权重时应考虑到设定训练速度。

      con.weight+=con.deltaWeight=(delta!=0 ? eta*delta : 0)+(con.deltaWeight!=0 ? alpha*con.deltaWeight : 0);
     }
//---
   return true;  
  }

调整所有权重之后,退出方法。

第一篇文章中已详细讲述了 CNeuron 类。 它没有太大变化,因此在此不再赘述。

3.4. 创建一个卷积神经网络类。

现在,所需的砖块均已创建完毕,我们可以着手建造房屋了。 我们将创建一个卷积神经网络类,它将所有类型的神经元组合到一个清晰的结构当中,并为我们的神经网络规划操作。 创建该类时出现的第一个问题是如何设置所需的网络结构。 在完全连接感知器的情况下,我们传递了一个元素数组,其中包含有关每一层神经元数量的信息。 现在,我们需要更多信息来生成所需的网络层。 我们来创建一个小型类 CLayerDescription,用它来描述层的构造。 该类不包含任何方法(构造函数和析构函数除外),且仅包含一些变量,用于指定层中神经元类型、神经元数量、窗口大小以及卷积和子抽样层中神经元步长。 指向含有层描述的类数组指针将通过卷积网络类构造函数的参数进行传递。

class CLayerDescription    :  public CObject
  {
public:
                     CLayerDescription(void);
                    ~CLayerDescription(void){};
//---
   int               type;
   int               count;
   int               window;
   int               step;
  };
//+------------------------------------------------------------------+
//|                                                                  |
//+------------------------------------------------------------------+
CLayerDescription::CLayerDescription(void)   :  type(defNeuron),
                                                count(0),
                                                window(1),
                                                step(1)
  {}

我们来研究一下 CNetConvolution 卷积神经网络类的结构。 该类包含:

class CNetConvolution
  {
public:
                     CNetConvolution(CArrayObj *Description);
                    ~CNetConvolution(void)                     {  delete layers; }
   bool              feedForward( CArrayDouble *inputVals);
   void              backProp( CArrayDouble *targetVals);
   void              getResults(CArrayDouble *&resultVals) ;
   double            getRecentAverageError()                   { return recentAverageError; }
   bool              Save( string file_name, double error, double undefine, double forecast, datetime time, bool common=true);
   bool              Load( string file_name, double &error, double &undefine, double &forecast, datetime &time, bool common=true);
   //---
   static double     recentAverageSmoothingFactor;
   virtual int       Type(void)   const   {  return defNetConv;   }

private:
   CArrayLayer       *layers;
   double            recentAverageError;
  };

方法名称和构造算法与完全连接感知器的名称和构造算法相似,这在第一篇文章中已有讲述。 我们仅关注该类的主要方法。

3.4.1. 卷积神经网络类构造函数。

研究类构造函数。 构造函数从参数中接收指向构建网络的层描述数组的指针。 因此,我们需要检查收到的指针的有效性,判断层数,并创建新层实例的数组。 

CNetConvolution::CNetConvolution(CArrayObj *Description)
  {
   if(CheckPointer(Description)==POINTER_INVALID)
      return;
//---
   int total=Description.Total();
   if(total<=0)
      return;
//---
   layers=new CArrayLayer();
   if(CheckPointer(layers)==POINTER_INVALID)
      return;

接下来,声明内部变量。

   CLayer *temp;
   CLayerDescription *desc=NULL, *next=NULL, *prev=NULL;
   CNeuronBase *neuron=NULL;
   CNeuronProof *neuron_p=NULL;
   int output_count=0;
   int temp_count=0;

准备工作至此完成。 我们直接循环生成神经网络层。 在循环开始,读取有关当前层和下一层的信息。

   for(int i=0;i<total;i++)
     {
      prev=desc;
      desc=Description.At(i);
      if((i+1)<total)
        {
         next=Description.At(i+1);
         if(CheckPointer(next)==POINTER_INVALID)
            return;
        }
      else
         next=NULL;

计数层的输出连接数,并创建神经层类的新实例。 请注意,层输出处的连接数仅应在完全连接层之前指定,否则指定为零。 这是因为卷积神经元自己存储输入权重,而子抽样层根本不会用到它们。

      int outputs=(next==NULL || next.type!=defNeuron ? 0 : next.count);
      temp=new CLayer(outputs);

然后,生成神经元,并根据该层神经元的类型进行算法划分。 对于完全连接层,将创建一个新的神经元实例,并初始化。 请注意,对于完全连接层,除了描述中指示的数字外,还会创建另外一个神经元。 该神经元将用作贝叶斯偏差。

      for(int n=0;n<(desc.count+(i>0 && desc.type==defNeuron ? 1 : 0));n++)
        {
         switch(desc.type)
           {
            case defNeuron:
              neuron=new CNeuron();
              if(CheckPointer(neuron)==POINTER_INVALID)
                {
                 delete temp;
                 delete layers;
                 return;
                }
              neuron.Init(outputs,n);
              break;

为卷积层创建一个新的神经元实例。 根据有关前一层的信息,计数输出元素的数量,并初始化新创建的神经元。

            case defNeuronConv:
              neuron_p=new CNeuronConv();
              if(CheckPointer(neuron_p)==POINTER_INVALID)
                {
                 delete temp;
                 delete layers;
                 return;
                }
              if(CheckPointer(prev)!=POINTER_INVALID)
                {
                 if(prev.type==defNeuron)
                   {
                    temp_count=(int)((prev.count-desc.window)%desc.step);
                    output_count=(int)((prev.count-desc.window-temp_count)/desc.step+(temp_count==0 ? 1 : 2));
                   }
                 else
                    if(n==0)
                      {
                       temp_count=(int)((output_count-desc.window)%desc.step);
                       output_count=(int)((output_count-desc.window-temp_count)/desc.step+(temp_count==0 ? 1 : 2));
                      }
                }
              if(neuron_p.Init(outputs,n,desc.window,desc.step,output_count))
                 neuron=neuron_p;
              break;

将类似的算法应用于子抽样层中的神经元。

            case defNeuronProof:
              neuron_p=new CNeuronProof();
              if(CheckPointer(neuron_p)==POINTER_INVALID)
                {
                 delete temp;
                 delete layers;
                 return;
                }
              if(CheckPointer(prev)!=POINTER_INVALID)
                {
                 if(prev.type==defNeuron)
                   {
                    temp_count=(int)((prev.count-desc.window)%desc.step);
                    output_count=(int)((prev.count-desc.window-temp_count)/desc.step+(temp_count==0 ? 1 : 2));
                   }
                 else
                    if(n==0)
                      {
                       temp_count=(int)((output_count-desc.window)%desc.step);
                       output_count=(int)((output_count-desc.window-temp_count)/desc.step+(temp_count==0 ? 1 : 2));
                      }
                }
              if(neuron_p.Init(outputs,n,desc.window,desc.step,output_count))
                 neuron=neuron_p;
              break;
           }

声明并初始化神经元后,将其添加到神经层。

         if(!temp.Add(neuron))
           {
            delete temp;
            delete layers;
            return;
           }
         neuron=NULL;
        }

循环生成下一层神经元的循环完成,就将该层添加到存储之中。 生成所有层后退出该方法。

      if(!layers.Add(temp))
        {
         delete temp;
         delete layers;
         return;
        }
     }
//---
   return;
  }

3.4.2. 卷积神经网络前行传播方法。

神经网络的完整操作会在 feedForward 前行推算方法里进行规划。 此方法在参数中接收要分析的原始数据(在我们的示例中,此数据是来自价格图表和指标的信息)。 首先,我们检查收到的数据数组之引用的有效性,以及神经网络的初始化状态。

bool CNetConvolution::feedForward(CArrayDouble *inputVals)
  {
   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;
   for(int i=0;i<total;i++)
     {
      neuron=current.At(i);
      if(CheckPointer(neuron)==POINTER_INVALID)
         return false;
      neuron.setOutputVal(inputVals.At(i));
     }

将源数据加载到神经网络之后,运行循环遍历从神经网络输入到其输出的所有神经层。

   CObject *temp=NULL;
   for(int l=1;l<layers.Total();l++)
     {
      previous=current;
      current=layers.At(l);
      if(CheckPointer(current)==POINTER_INVALID)
         return false;

在启动的循环内,为每个层运行一个嵌套循环,遍历该层中的所有神经元,并重新计算其值。 请注意,对于完全连接神经层,不会重新计算最后一个神经元的值。 如上所述,该神经元被用作贝叶斯偏差,因此仅会用到其权重。

      total=current.Total();
      if(current.At(0).Type()==defNeuron)
         total--;
//---
      for(int n=0;n<total;n++)
        {
         neuron=current.At(n);
         if(CheckPointer(neuron)==POINTER_INVALID)
            return false;

进而,方法的选择取决于前一层中神经元的类型。 对于完全连接层,调用前行传播方法,并在其参数中指定前一层的引用。

         if(previous.At(0).Type()==defNeuron)
           {
            temp=previous;
            if(!neuron.feedForward(temp))
               return false;
            continue;
           }

如果以前存在卷积或子抽样层,则检查重新计算的神经元类型。 对于完全连接层的神经元,将前一层的所有神经元的内层收集到单个层中,然后参考参数中指定的神经元的总层数,调用当前神经元的前行传播方法。 

         if(neuron.Type()==defNeuron)
           {
            if(n==0)
              {
               CLayer *temp_l=new CLayer(total);
               if(CheckPointer(temp_l)==POINTER_INVALID)
                  return false;
               CNeuronProof *proof=NULL;
               for(int p=0;p<previous.Total();p++)
                 {
                  proof=previous.At(p);
                  if(CheckPointer(proof)==POINTER_INVALID)
                     return false;
                  temp_l.AddArray(proof.getOutputLayer());
                 }
               temp=temp_l;
              }
            if(!neuron.feedForward(temp))
               return false;
            if(n==total-1)
              {
               CLayer *temp_l=temp;
               temp_l.FreeMode(false);
               temp_l.Shutdown();
               delete temp_l;
              }
            continue;
           }

一旦该层所有神经元的循环完成,则删除整个层对象。 在此,有必要删除该层对象,而无需删除该层中包含的神经元对象,因为在我们的卷积和子抽样层中将继续使用相同的对象。 这可以通过将 m_free_mode 标志设置为 false 状态,然后删除该对象来完成。

如果这是卷积或子抽样层的元素,则采用前行传播方法,将指向相应过滤器前一个元素的指针作为参数传递。

         temp=previous.At(n);
         if(CheckPointer(temp)==POINTER_INVALID)
            return false;
         if(!neuron.feedForward(temp))
            return false;
        }
     }
//---
   return true;
  }

遍历所有神经元和层之后,退出该方法。

3.4.3. 卷积神经网络回退传播方法。

调用 backProp 回退传播方法训练神经网络。 它实现了从神经网络的输出层到其输入层的回退误差传播方法。 因此,该方法在参数中接收实际数据。

在方法开始时,检查指向数值对象的指针的有效性。

void CNetConvolution::backProp(CArrayDouble *targetVals)
  {
   if(CheckPointer(targetVals)==POINTER_INVALID)
      return;

然后,计算神经网络前行推算的输出的均方根误差,与实际数据相比,并计算输出层神经元的误差梯度。

   CLayer *outputLayer=layers.At(layers.Total()-1);
   if(CheckPointer(outputLayer)==POINTER_INVALID)
      return;
//---
   double error=0.0;
   int total=outputLayer.Total()-1;
   for(int n=0; n<total && !IsStopped(); n++)
     {
      CNeuron *neuron=outputLayer.At(n);
      double target=targetVals.At(n);
      double delta=(target>1 ? 1 : target<-1 ? -1 : target)-neuron.getOutputVal();
      error+=delta*delta;
      neuron.calcOutputGradients(targetVals.At(n));
     }
   error/= total;
   error = sqrt(error);

   recentAverageError+=(error-recentAverageError)/recentAverageSmoothingFactor;

 下一步是组织所有神经网络层的回退循环。 在此,我们运行一个嵌套循环遍历相应层的所有神经元,以便重新计算隐藏层神经元的误差梯度。

   CNeuronBase *neuron=NULL;
   CObject *temp=NULL;
   for(int layerNum=layers.Total()-2; layerNum>0; layerNum--)
     {
      CLayer *hiddenLayer=layers.At(layerNum);
      CLayer *nextLayer=layers.At(layerNum+1);
      total=hiddenLayer.Total();
      for(int n=0; n<total && !IsStopped(); ++n)
        {

与前行传播方法相似,根据当前神经元和下一层神经元的类型,选择更新误差梯度所需的方法。 若接下来是一个完全连接神经元层,则调用被分析神经元的 calcHiddenGradients 方法,在参数中传递指向神经网络下一层对象的指针。

         neuron=hiddenLayer.At(n);
         if(nextLayer.At(0).Type()==defNeuron)
           {
            temp=nextLayer;
            neuron.calcHiddenGradients(temp);
            continue;
           }

如果后随卷积或子抽样层,则检查当前神经元的类型。 对于完全连接神经元,遍历下一层的所有过滤器,同时针对给定神经元,启动每个过滤器的误差梯度重新计算。 然后把得出的梯度汇总。 若当前层也是卷积或子抽样层,则采用相应的滤波器判断误差梯度。

         if(neuron.Type()==defNeuron)
           {
            double g=0;
            for(int i=0;i<nextLayer.Total();i++)
              {
               temp=nextLayer.At(i);
               neuron.calcHiddenGradients(temp);
               g+=neuron.getGradient();
              }
            neuron.setGradient(g);
            continue;
           }
         temp=nextLayer.At(n);
         neuron.calcHiddenGradients(temp);
        }
     }

所有梯度更新之后,用相同的分支逻辑运行类似的循环,从而更新神经元权重。 权重更新完毕后,退出方法。

   for(int layerNum=layers.Total()-1; layerNum>0; layerNum--)
     {
      CLayer *layer=layers.At(layerNum);
      CLayer *prevLayer=layers.At(layerNum-1);
      total=layer.Total()-(layer.At(0).Type()==defNeuron ? 1 : 0);
      int n_conv=0;
      for(int n=0; n<total && !IsStopped(); n++)
        {
         neuron=layer.At(n);
         if(CheckPointer(neuron)==POINTER_INVALID)
            return;
         if(neuron.Type()==defNeuronProof)
            continue;
         switch(prevLayer.At(0).Type())
           {
            case defNeuron:
              temp=prevLayer;
              neuron.updateInputWeights(temp);
              break;
            case defNeuronConv:
            case defNeuronProof:
              if(neuron.Type()==defNeuron)
                {
                 for(n_conv=0;n_conv<prevLayer.Total();n_conv++)
                   {
                    temp=prevLayer.At(n_conv);
                    neuron.updateInputWeights(temp);
                   }
                }
              else
                {
                 temp=prevLayer.At(n);
                 neuron.updateInputWeights(temp);
                }
              break;
            default:
              temp=NULL;
              break;
           }
        }   
     }
  }

下面的附件中提供了所有方法和类的完整代码。 

4. 测试

为了测试卷积神经网络的操作,我们借用本系列第二篇文章中的分类智能交易系统。 神经网络的目的是学习预测当前烛条上的分形。 为此目的,将有关最后 N 根烛条形态的信息,以及来自同周期的 4 个振荡器的数据馈入神经网络。

在神经网络的卷积层中,创建 4 个过滤器,这些过滤器将依据全部烛条形成数据,以及所分析烛条上的振荡器读数,搜索形态。 过滤器窗口和步骤将对应于每根烛条描述的数据量。 换句话说,这将取有关每根烛条的所有信息与特定形态进行比较,并返回收敛值。 这种方法能够用有关烛条的新信息(例如,添加更多指标进行分析等)补充初始数据,而不会造成明显的性能损失。

子抽样层中的特征数组尺寸会降低,且结果经均化后会更平滑。

EA 本身仅需很少的修改。 该修改适用于神经网络类,即变量的声明和实例的创建。

CNetConvolution     *Net;

其他修改涉及在 OnInit 函数中设置神经网络结构的部分。 测试是使用一个网络进行的,该网络含有一个卷积层和一个子抽样层,每个层都有 4 个过滤器。 完全连接层的结构没有改变(有意评估卷积层对整个网络运行的影响)。 

   Net=new CNetConvolution(NULL);
   ResetLastError();
   if(CheckPointer(Net)==POINTER_INVALID || !Net.Load(FileName+".nnw",dError,dUndefine,dForecast,dtStudied,false))
     {
      printf("%s - %d -> Error of read %s prev Net %d",__FUNCTION__,__LINE__,FileName+".nnw",GetLastError());
      CArrayObj *Topology=new CArrayObj();
      if(CheckPointer(Topology)==POINTER_INVALID)
         return INIT_FAILED;
//---
      CLayerDescription *desc=new CLayerDescription();
      if(CheckPointer(desc)==POINTER_INVALID)
         return INIT_FAILED;
      desc.count=(int)HistoryBars*12;
      desc.type=defNeuron;
      if(!Topology.Add(desc))
         return INIT_FAILED;
//---
      int filters=4;
      desc=new CLayerDescription();
      if(CheckPointer(desc)==POINTER_INVALID)
         return INIT_FAILED;
      desc.count=filters;
      desc.type=defNeuronConv;
      desc.window=12;
      desc.step=12;
      if(!Topology.Add(desc))
         return INIT_FAILED;
//---
      desc=new CLayerDescription();
      if(CheckPointer(desc)==POINTER_INVALID)
         return INIT_FAILED;
      desc.count=filters;
      desc.type=defNeuronProof;
      desc.window=3;
      desc.step=2;
      if(!Topology.Add(desc))
         return INIT_FAILED;
//---
      int n=1000;
      bool result=true;
      for(int i=0;(i<4 && result);i++)
        {
         desc=new CLayerDescription();
         if(CheckPointer(desc)==POINTER_INVALID)
            return INIT_FAILED;
         desc.count=n;
         desc.type=defNeuron;
         result=(Topology.Add(desc) && result);
         n=(int)MathMax(n*0.3,20);
        }
      if(!result)
        {
         delete Topology;
         return INIT_FAILED;
        }
//---
      desc=new CLayerDescription();
      if(CheckPointer(desc)==POINTER_INVALID)
         return INIT_FAILED;
      desc.count=3;
      desc.type=defNeuron;
      if(!Topology.Add(desc))
         return INIT_FAILED;
      delete Net;
      Net=new CNetConvolution(Topology);
      delete Topology;
      if(CheckPointer(Net)==POINTER_INVALID)
         return INIT_FAILED;
      dError=-1;
      dUndefine=0;
      dForecast=0;
      dtStudied=0;
     }

智能交易系统代码的其余部分保持不变。

使用 EURUSD 货币对和 H1 时间帧执行测试。 在同一终端的同一品种的不同图表上同时启动了两个智能交易系统,一个含有卷积神经网络,另一个含有完全连接网络。 卷积神经网络的完全连接层的参数,与第二个智能交易系统的完全连接网络的参数匹配。 e. 我们只是简单地将卷积层和子抽样层添加到先前创建的网络中。

测试表明,在卷积神经网络中性能有小幅提升。 尽管附加了两层,卷积神经网络的一个时期(基于 24 个时期的结果)的平均训练时间为 2 小时 4 分钟,而完全连接网络的平均训练时间为 2 小时 10 分钟。

 

卷积神经网络在预测误差和“目标命中”方面显示出稍好的结果。


直观上,您可以看到信号在卷积神经网络图上出现的频率较低,但是它们更接近目标。

卷积神经网络测试。

全连接神经网络测试


结束语

在本文中,我们研究了在金融市场中运用卷积神经网络的可能性。 测试表明,通过运用它们,我们可以改善完全连接神经网络的结果。 这与我们馈入完全连接感知器的数据预处理有关。 在卷积和子抽样层中对原始数据进行过滤,剔除噪声,从而提高了源数据和神经网络的质量。 甚而,降低的维度有助于减少感知器与原始数据的连接数,从而提高性能。


参考文献列表

  1. 神经网络变得轻松
  2. 神经网络变得轻松(第二部分):网络训练和测试

本文中用到的程序

# 发行 类型 说明
1 Fractal.mq5   智能交易系统  一款含有回归神经网络(输出层中有 1 个神经元)的智能交易系统
2 Fractal_2.mq5  智能交易系统  一款含有分类神经网络的智能交易系统(输出层中有 3 个神经元)
3 NeuroNet.mqh  类库  创建神经网络(感知器)的类库
4 Fractal_conv.mq5   智能交易系统  含有卷积神经网络(输出层中 3 个神经元)的神经智能交易系统