English Русский Español Deutsch 日本語 Português
preview
利用 MQL5 矩阵的反向传播神经网络

利用 MQL5 矩阵的反向传播神经网络

MetaTrader 5示例 | 17 七月 2023, 11:17
864 0
Stanislav Korotky
Stanislav Korotky

机器学习,特别是神经网络,很久以前就已经成为交易者工具箱的一部分。 在神经网络方面,部分使用“监督学习”方法,其中反向传播神经网络(BPNN)占据了特殊的地位。 此类算法有许多不同的改编版。 例如,它们被用作深度、循环和卷积神经网络的基础。 如此,我们不必对有关此主题的大量材料(以及本网站上的文章)感到惊讶。 今天,我们将从一个相对 MQL5 来说较新颖的方向来讨论这个主题。 这是因为前段时间 MQL5 引入了新的 API 功能,旨在处理矩阵和向量。 它们能够在神经网络中实现批量计算,期间,它会把数据作为一个整体(以区块为单位),而不是逐个元素处理。

矩阵运算的使用大大简化了具现网络前馈和反向传播公式的程序指令。 这些运算实际上转换为单行表达式。 有其助力,我们可以专注于其它重要方面,从而改进算法。

在本文中,我们将简要回顾反向传播网络的理论,并运用该理论创建用于构建网络的通用类:上述公式都将近乎原样反映在源代码之中。 因此,初学者可以在学习这项技术时贯通所有步骤,而无需寻找第三方发布的资源。

如果您已经了解该理论,那么您就可以放心地转到本文的第二部分,其内讨论了类在脚本、指标、和智能系统中的实际用法。


神经网络理论概述

神经网络由简单的计算元素、神经元组成,它们通常在逻辑上组合为层,并通过信号通过的连接(突触)连接。 信号是一种数学抽象,可用于表示各种应用领域的情况,包括交易。

突触将一个神经元的输出连接到另一个神经元的输入。 它通过权重 wi 来表征。 神经元的当前状态是在其连接(输入)上接收的信号的加权合计。

神经元逻辑示意图

神经元逻辑示意图

该状态利用另外的非线性激活函数进行处理,该函数生成特定神经元的输出值。 从输出开始,信号将沿着下一个连接的神经元(如果有的话)的突触进一步传播,或者将成为神经网络响应的组成部分(如果当前神经元位于最后一层)。

f1
(1)
f2
(2)

非线性的存在增强了网络的计算能力。 我们可以使用不同的激活函数,例如双曲正切或一个逻辑函数(它们就是所谓的 S-形或西格玛函数):

f3
(3)

正如我们将在下面看到的,MQL5 提供了大量的内置激活函数。 函数的选择应基于特定问题(回归、分类)。 通常,可以选择几个函数,然后经由验正找到最优的一个。

流行的激活函数

流行的激活函数

激活函数可以具有不同的数值范围、有限或无限。 特别是,sigmoid(3) 将数据映射到范围 [0,+1],这对于分类问题更好;而双曲正切将数据映射到范围 [-1,+1],假设范围,推测这对于回归和预测问题更佳。

激活函数的一个重要特性是如何沿整个数轴定义其导数。 有限的存在,非零导数对于反向传播算法至关重要,这个我们稍后将予以讨论。 S-形函数能满足这一需求。 甚至,标准激活函数通常具有相当简单的导数解析注释符,这保证了它们的有效计算。 例如,对于 sigmoid (3),我们得到:

f4
(4)

下图展示的是单层神经网络。

单层神经网络

单层神经网络

其工作原理可以在数学上用以下方程描述:

f5
(5)

显然,一层的所有权重系数都可以放入 W 矩阵之中,其中每个 wij 元素设置第 j 个神经元的第 i 个连接的值。 故此,神经网络中正发生的过程可以用矩阵形式写出:

Y = F(X W) (6)

其中 X 和 Y 分别是输入和输出信号向量;F(V) 是应用于向量 V 分量的逐元素激活函数。

层数和每层神经元的数量取决于输入数据:它们的维度、数据集规模、分布规律和许多其它因素。 通常,网络配置是通过反复试验进行选取的。

为了描绘这一点,我将展示一个两层网络的示意图。

两层神经网络

两层神经网络

现在来研究一下我们错过的一点。 从激活函数的图例中可以明显看出有一些 T 值,其中 S-形函数具有最大斜率,并且转移信号良好,而其它函数具有特征断点(或若干个这样的点)。 因此,每个神经元的主要工作发生在 T 附近,通常 T=0 或位于 0 附近,因此希望能够自动将激活函数的参数平移到 T。

这种现象没有反映在公式(1)当中,其应该是这个样子的:

f7
(7)

这种转换通常是通过向神经层添加另一个伪输入来实现的。 此伪输入的值始终为 1。 我们为此输入分配数字 0。 然后:

f8
(8)

其中 w0 = –T, x0 = 1。

在监督学习算法中,我们有事先由人类专家准备和标记的训练数据。 在此数据中,所需的输出向量与输入向量相关联。

训练过程分以下阶段实施。

1. 初始化权重矩阵元素(通常是一些小随机值)。

2. 输入其中一个向量,并计算网络反应 — 这是信号的前向传播;此阶段也会在经过训练的网络正常运行期间使用。

3. 计算理想值和生成的输出值之间的差异,以便查找网络误差,然后依据该误差,并遵照某些公式调整权重。

4. 继续从步骤 2 循环处理数据集的所有输入向量,直到误差降低到指定的最小水平或更低(成功完成训练),或达到预定义的最大训练循环数(神经网络失败)。

对于单层网络,权重调整公式非常简单:

f9
(9)
f10
(10)

其中 δ 是网络误差(网络响应与理想响应之间的差值);t 和 t+1 是当前和下一次迭代的数字;ν 是学习率,0<ν<1;i 是输入索引;j 是层中神经元的索引。

但是在多层网络的情况下该怎么办? 这就是我们想出反向传播思路的所在。


反向传播算法

最著名的神经网络结构之一是多层结构,其中特定层的每个神经元连接到前一层的所有神经元,或者如果是第一层,则连接到所有网络输入。 这种神经网络被称为完全连接。 对此结构提供了进一步的解释。 在许多其它类型的神经网络中,特别是在卷积网络中,链接连接层的有限区域,即所谓的核心,这在一定程度上令网络元素的寻址复杂化,但这并不影响反向传播方法的适用性。

显然,有关误差的信息应该以某种方式从网络输出传递到其输入端,逐级穿过所有层,同时考虑到层的“电导率”,即权重。

根据最小二乘法,网络误差最小化的目标函数为以下值:

f11
(11)

其中 yjpᴺ 是第 p 个图像输入到输出层 N 的神经元 j 的真实输出状态;djp 是该神经元的理想(期望)输出状态。

针对输出层的所有神经元实现求和,并覆盖所有经处理的图像。 添加的速率 1/2 只是为了得到 E 的一个优美导数(两次被取消),这将进一步用于训练(参见方程(12)),并且在任何情况下进行加权都通过算法的一个重要参数 — 速率(可以根据某些条件加倍或动态变化)。

最小化函数的最有效方法之一是基于以下内容:极值的最佳局部方向指示该函数在特定点的导数。 正导数导致最大值,负导数导致最小值。 当然,最大值和最小值也许只是局部的,可能需要额外的技巧才能达到全局最小值,但我们暂时将这个问题抛在脑后。

所描述的方法是梯度下降法。 据其,基于 E 导数调整权重如下:

f12
(12)

这里 wij 是第 n-1 层的第 i 个神经元和第 n 层的第 j 个神经元之间连接的权重,η 是学习率。

我们回到神经元的内部结构,并在此基础上将公式(12) 中的每个计算阶段分配到偏导数中:

f13
(13)

如前所述,yj 是神经元 j 的输出,而 sj 是其输入信号的加权和,即激活函数的参数。 由于因子 dyj/dsj 是该函数的导数,因此这设置了激活函数在整个 x 轴上应可微分的要求,以便在所研究的反向传播算法中使用。

例如,在双曲正切的情况下:

f14
(14)

在(13) 中的第三个因子 ∂sj/∂wij 等于前一层(n-1)的神经元输出 yi。 为什么? 在多层网络中,信号从前一层神经元的输出到当前层神经元的输入。 因此,对于 sj 的公式(1) 可以用更常见的方式重写如下:

f15
(15)

其中 M 是第 n-1 层中的神经元数量,考虑到具有恒定输出状态 +1 的神经元,该神经元设置偏移量;yi(n-1)=xij(n) 是第 n 层神经元 j 的第 i 个输入,它与第 (n-1) 层的第 i 个神经元的输出相连;

至于(13) 中的第一个因字,在下一个更高层以误差增量扩展它是合乎逻辑的(因为误差值以相反的方向传播):

f16
(16)

此处,k 的合计是在 n+1 层的神经元中实现的。

在(13)中一层(神经元指数 j)的前两个因子在(16)中重复,用于下一层(指数 k)作为权重 wjk 之前的系数。

我们引入了一个中间变量,其中包括以下两个因子:

f17
(17)

作为结果,我们得到一个递归公式,利用更高层 n+1 的 δk(n+1) 值来计算层 n 的 δj(n)。

f18
(18)

与以前一样,计算输出层的新变量是基于获得的结果与所需结果之间的差值。

f19
(19)

与(9)相比,这里我们得到了激活函数的导数。 请注意,在网络的输出层中,根据任务的不同,激活函数可能不存在。

现在我们可以编写公式(12) 的扩展来调整学习过程中的权重:

f20
(20)

有时,为了给权重调整过程一些惯性,在目标函数表面上移动时平滑导数中的急剧跳跃,公式(20)补充了上一次迭代中的权重变化:

f21
(21)

其中 μ 是惯性系数,t 是当前迭代的次数。

因此,使用反向传播过程构建的完整神经网络训练算法如下:

1. 用小随机数初始化权重矩阵。

2. 将其中一个数据向量输入到网络,在正常操作模式下,当信号从输入传播到输出时,遵照加权求和(15)和激活 f 公式逐层计算神经网络的总和结果:

f22
(22)

在此,零层的输入神经元仅用于馈送输入信号,没有突触和激活函数。

f23
(23)

Iq 是馈入零层的输入向量的第 q 个分量。

3. 如果网络误差小于指定的较小数值,我们就停止该过程,并认为成功。 如果错误仍然很大,则继续执行后续步骤。

4. 计算输出层 N:使用公式(19)计算 δ,以及使用公式(20)或(21)计算 Δw 值的变化。

5. 对于所有其它层,顺序相反,n=N-1,...1、分别使用公式(18)与(20)(或(18)与(21))计算 δ 和 Δw。

6. 基于上一次迭代 t-1,调整迭代 t 处神经网络的所有权重。

f24
(24)

7. 循环从步骤 2 开始重复该过程。

使用反向传播算法训练的网络信号图如以下图例所示。

反向传播算法中的信号

反向传播算法中的信号

所有训练图像交替输入网络,这样它就不会在记住其它图像时“忘记”某个图像。 通常这是以随机顺序完成的,但是由于我们将数据定位在矩阵中,并将它们按单个集合计算,因此我们将在实现中引入另一个随机性元素,稍后我们将讨论。

使用矩阵意味着所有层的权重,以及输入,目标训练数据,将由矩阵表示。 因此,上述公式以及相应的算法将接收矩阵形式。 换言之,我们不能用单独的输入向量和训练数据进行操作,而从步骤 2 到 7 的整个循环将立即针对整个数据集计算。 这样的一个循环称为学习世代。


激活函数概述

文章附件包含 AF.mq5 脚本,该脚本显示图表上由 MQL5(蓝色)支持的所有激活函数,及其衍生函数(红色)的缩略图。 该脚本会自动缩放缩略图,从而令所有函数适合窗口。 如果您需要获得更详细的图像,我建议最大化窗口。 脚本生成的图像示例如下所示。

激活函数的正确选择取决于神经网络类型和问题。 甚至,可以在一个网络中使用几种不同的激活函数。 例如,SoftMax 与其它函数的不同之处在于,它不是按元素处理层的输出值,而是相互连接:它把它们进行归一化,以便可以将数值解释为概率(它们的总和为 1),而这已在多重分类中使用。

这个主题非常广泛,需要单独的文章或一系列文章。 目前,您应该只留意所有函数都有优点和缺点,这可能会导致网络故障。 特别是,S-形函数的特征在于“梯度消失”问题,当信号开始落在 S-曲线的钝化部分,权重的调整由此趋于零时。 单调递增函数存在梯度爆发增长的问题(“梯度爆发”,因为权重不断增加导致数值溢出,和 NaN(非数字))。 网络的层数越多,遇到这两个问题的可能性就越大。 有多种技术可以解决这些问题,例如数据规范化(输入层和中间层)、网络细化算法(“dropout”)、批量学习、噪声和其它正则化方法。 我们将深入研究其中的一些。

含有所有激活函数的演示脚本

含有所有激活函数的演示脚本


以 MatrixNet 类实现神经网络

我们开始编写一个基于 MQL5 矩阵的神经网络类。 网络由多层组成,因此我们将描述每层神经元的权重数组和输出值。 层数将存储在变量 n 之中,而每层输出的神经元权重和信号将分别存储在 “weights” 和 “outputs” 矩阵当中。 请注意,“outputs” 是指任何层神经元输出的信号,而不仅仅是网络输出的信号。 故此,outputs[i] 还代表了输入数据所写入的层,可以是中间层甚至零层。

下图显示了 “weights” 和 “outputs” 数组的索引(为简单起见,未显示每个神经元与 +1 移位源的连接):

两层网络中矩阵数组的索引

两层网络中矩阵数组的索引

数字 n 不包括输入层,因为该层不需要权重。

  class MatrixNet
  {
  protected:
     const int n;
     matrix weights[/* n */];
     matrix outputs[/* n + 1 */];
     ENUM_ACTIVATION_FUNCTION af;
     ENUM_ACTIVATION_FUNCTION of;
     double speed;
     bool ready;
     ...

我们的网络将支持两种类型的激活函数(由用户选择):一种用于除输出之外的所有层(存储在 “af” 变量中),另一种用于输出层(存储在 “of” 变量中)。 “speed” 变量存储学习率(公式(20)中的 η 系数)。

“ready” 变量包含成功初始化 N 个对象的指示。

网络构造函数接收整数数组 “layers”,它定义了所有层的数量和大小。 零元素设置输入伪层的大小,即每个输入向量中的特征数。 最后一个元素判定输出层的大小,而其余所有元素定义中间隐藏层。 必须至少有两个层。 已经编写了额外的方法 “allocate” 来为矩阵数组分配内存(随着类的扩展,我们将进一步开发它)。

  public:
     MatrixNet(const int &layers[], const ENUM_ACTIVATION_FUNCTION f1 = AF_TANH,
        const ENUM_ACTIVATION_FUNCTION f2 = AF_NONE):
        ready(false), af(f1), of(f2), n(ArraySize(layers) - 1)
     {
        if(n < 2) return;
        
        allocate();
        for(int i = 1; i <= n; ++i)
        {
           // NB: the weights matrix is transposed, i.e. indexes [row][column] specify [synapse][neuron]
           weights[i - 1].Init(layers[i - 1] + 1, layers[i]);
        }
        ...
     }
        
  protected:
     void allocate()
     {
        ArrayResize(weights, n);
        ArrayResize(outputs, n + 1);
        ...
     }

为了初始化每个权重矩阵,将前一层 layers[i - 1] 的大小作为行数,并向其中添加一个突触,从而获得恒定可调的移位源 +1。 我们使用当前层 layers[i] 的大小作为列数。 在每个权重矩阵中,第一个索引指向矩阵左侧的层,而第二个索引指向右侧的那个层。

在前向传播(正常网络操作)期间,这种编号为信号向量乘以层矩阵提供了简单的记录。 在误差反向传播过程中(在训练模式下),有必要将每个较高层的误差向量乘以其转置的权重矩阵,以便为较低层重新计算误差。

换言之,由于网络内部的信息以两个相反的方向移动 — 操作信号从输入流向输出,以及误差从输出流向输入 — 这两个方向的权重矩阵的第一个应该以通常的形式使用,而第二个方向则应被转置。 对于正常配置,我们使用矩阵标记,这有助于计算直接信号。

我们将沿着信号贯穿网络的过程填充 “outputs” 矩阵。 而至于权重,它们应是随机初始化的。 这是通过在构造函数末尾调用 “randomize” 方法来完成的。

  public:
     MatrixNet(const int &layers[], const ENUM_ACTIVATION_FUNCTION f1 = AF_TANH,
        const ENUM_ACTIVATION_FUNCTION f2 = AF_NONE):
        ready(false), af(f1), of(f2), n(ArraySize(layers) - 1)
     {
        ...
        ready = true;
        randomize();
     }
     
     // NB: set values with appropriate distribution for specific activation functions
     void randomize(const double from = -0.5, const double to = +0.5)
     {
        if(!ready) return;
        
        for(int i = 0; i < n; ++i)
        {
           weights[i].Random(from, to);
        }
     }

权重矩阵的存在足以实现从网络输入到输出的前馈传递。 权重尚未经过训练不是大问题,因为我们稍后才会应对训练。

     bool feedForward(const matrix &data)
     {
        if(!ready) return false;
        
        if(data.Cols() != weights[0].Rows() - 1)
        {
           PrintFormat("Column number in data %d <> Inputs layer size %d",
              data.Cols(), weights[0].Rows() - 1);
           return false;
        }
        
        outputs[0] = data; // input the data to the network
        for(int i = 0; i < n; ++i)
        {
           // expand each layer (except the last one) with one neuron for the bias signal
           // (there is no weight matrix to the right of the last layer, since the signal does not go further)
           if(!outputs[i].Resize(outputs[i].Rows(), weights[i].Rows()) ||
              !outputs[i].Col(vector::Ones(outputs[i].Rows()), weights[i].Rows() - 1))
              return false;
           // forward the signal from i-th layer to the (i+1)-th layer: weighted sum
           matrix temp = outputs[i].MatMul(weights[i]);
           // apply the activation function, the result is received into outputs[i + 1]
           if(!temp.Activation(outputs[i + 1], i < n - 1 ? af : of))
              return false;
        }
        
        return true;
     }

输入矩阵数据中的列数必须与零权重矩阵中的行数减 1(偏差信号的权重)匹配。

若要读取常规网络操作的结果,则需调用 getResults 方法。 默认情况下,它返回输出层状态矩阵。

     matrix getResults(const int layer = -1) const
     {
        static const matrix empty = {};
        if(!ready) return empty;
        
        if(layer == -1) return outputs[n];
        if(layer < -1 || layer > n) return empty;
        
        return outputs[layer];
     }

我们可以调用 “test” 方法评估模型的当前品质,不仅可以将输入数据矩阵输入其中,还可以将含有所需网络响应的矩阵输入其中。

     double test(const matrix &data, const matrix &target, const ENUM_LOSS_FUNCTION lf = LOSS_MSE)
     { 
        if(!ready || !feedForward(data)) return NaN();
        
        return outputs[n].Loss(target, lf);
     }

在调用 feedForward 方法进行前馈验算之后,此处我们计算给定类型的“损失”。 默认情况下,这是均方根误差(LOSS_MSE),适用于回归和预测问题。 不过,如果要将网络用于图像分类,我们应该使用不同类型的评分,例如交叉熵 LOSS_CCE。

如果发生计算错误,该方法将返回 NaN(非数字)。

现在我们继续进行反向传播。 首先,backProp 方法检查目标数据和输出层的大小是否匹配。 它计算输出层(如果有)的激活函数的导数,以及输出处相对于目标数据的网络“损失”。

     bool backProp(const matrix &target)
     {
        if(!ready) return false;
     
        if(target.Rows() != outputs[n].Rows() ||
           target.Cols() != outputs[n].Cols())
           return false;
        
        // output layer
        matrix temp;
        if(!outputs[n].Derivative(temp, of))
           return false;
        matrix loss = (outputs[n] - target) * temp; // all data line by line

损失矩阵包含公式(19)中的 δ 值。

接下来,执行循环遍历除输出层之外的所有层:

        for(int i = n - 1; i >= 0; --i) // all layers except the output in reverse order
        {
           // remove pseudo-losses in the last element which we added as an offset source
           // since it is not a neuron and further error propagation is not applicable to it
           // (we do it in all layers except the last one where the shift element was not added)
           if(i < n - 1) loss.Resize(loss.Rows(), loss.Cols() - 1);
           
           matrix delta = speed * outputs[i].Transpose().MatMul(loss);

在此,我们看到了确切的公式(20):我们根据学习率得到权重增量 η — 当前层的 δ 和前一层(较低)的相关输出。

接下来,对于每一层,我们计算公式(18),递归获得其余 δ 值:我们再次使用激活函数的导数,以及较高 δ 与转置权重矩阵的乘积。 outputs[] 矩阵中的索引 i 与 weights[] 矩阵中的第(i-1)权重层相对应,因为输入伪层(outputs[0])没有权重。 换言之,在前向传播中,weights[0] 矩阵应用于 outputs[1],并生成 outputs[2];而 weights[1] 生成 outputs[2],依此类推。 与之对比,在反向传播中,索引是相同的:例如,outputs[2](微分后)乘以已转置的 weights[2]。

           if(!outputs[i].Derivative(temp, af))
              return false;
           loss = loss.MatMul(weights[i].Transpose()) * temp;

在计算更低层的“损失” δ 之后,我们可以通过针对较早获得的 delta 增量校正权重来调整权重[i]矩阵权重。

           weights[i] -= delta;
        }
        return true;
     }

现在,我们已近乎准备好实现一个完整的学习算法,其中包含一个循环的世代,以及 feedForward 和 backProp 方法调用。 不过,我们必须首先回顾我们之前推迟的一些理论细微差别。


训练和正规化

神经网络根据当前可用的训练数据进行训练。 网络配置(层数、层中神经元数、等等)、学习率和其它特征都是根据这些数据选取的。 故此,始终可以构建一个足够强大的网络,以便基于训练数据产生足够小的误差。 不过,使用神经网络的最终目的是令其面对将来的未知数据上表现优良(具有与训练数据集中相同的隐式依赖关系)。

当经过训练的神经网络在训练数据上表现得太好,但在前向测试中失利时,这种影响称为过度拟合。 这种影响应以各种可能的方式加以避免。 为了避免过度拟合,我们可以使用正则化。 这意味着引入一些额外的条件来评估网络的泛化能力。 有许多不同的方式可以正规化,特别是:

  • 依据追加验证数据集(不同于训练数据集),分析已训练网络的性能
  • 在训练期间随机舍弃一部分神经元或连接
  • 训练后网络修剪
  • 在输入数据中引入噪声
  • 人工数据复现
  • 训练期间权重振幅的微弱恒定下降
  • 实验选择交易量和精细网络配置,当网络仍然能够学习,但不会依赖可用数据过度拟合时

我们将在类中实现其中的一些。

首先,我们不仅在训练方法中启用输入和输出训练数据(分别为 “data” 和 “target” 参数),还可以输入验证数据集(它还包括输入和相关输出向量:“validation” 和 “check”)。

随着训练进度,训练数据上的网络误差正常下会十分单调地减少(我使用“正常”,因为如果学习率或网络容量选择不正确,该过程可能会变得不稳定)。 不过,如果我们在此过程中计算验证集上的网络误差,它会首先减少(期间网络揭示数据当中最重要的形态),然后它就开始因过度拟合而增长(当网络匹配训练数据集的特定特征时,而不是验证集)。 因此,当验证误差开始上升时,应停止学习过程。 这就是“提早停止”的方式。

除了两个数据集之外,“train” 方法还允许指定最大训练世代次数、所需的精度(即可接受的平均最小误差:在这种情况下,训练也会因成功指示而停止)和误差计算方法(lf)。

学习率(“speed”)设置为等于“准确性”,但可以将它们设置为不同值,以便增加设置的灵活性。 这是因为速率将自动调整,因此初始近似值并不那么重要。

     double train(const matrix &data, const matrix &target,
        const matrix &validation, const matrix &check,
        const int epochs = 1000, const double accuracy = 0.001,
        const ENUM_LOSS_FUNCTION lf = LOSS_MSE)
     {
        if(!ready) return NaN();
        
        speed = accuracy;
        ...

我们将当前世代网络误差值保存在变量 mse 和 msev 当中,用于训练和验证集。 为了排除对不可避免的随机波动的响应,我们需要把某个周期 p 的误差均化,该 p 是根据给定的总世代次数计算得出的。 平滑的误差值将存储在 msema 和 msevma 变量当中,它们以前的值将保存在 msemap 和 msevmap 变量中。

        double mse = DBL_MAX;
        double msev = DBL_MAX;
        double msema = 0;       // MSE averaging of the training set
        double msemap = 0;      // MSE averaging of the training set in the previous epoch
        double msevma = 0;      // MSE averaging of the validation dataset
        double msevmap = 0;     // MSE averaging of the validation dataset in the previous epoch
        double ema = 0;         // exponential smoothing factor
        int p = 0;              // EMA period
        
        p = (int)sqrt(epochs);  // empirically choose the period of the EMA averaging of errors
        ema = 2.0 / (p + 1);
        PrintFormat("EMA for early stopping: %d (%f)", p, ema);

接下来,我们运行一个训练世代循环。 我们允许不提供验证数据,因为稍后我们将实现另一种正则化方法 Dropout。 如果验证数据集不为空,我们调用 “test” 方法来基于此集合计算 msev。 在任何情况下,我们计算 mse 都需基于训练集调用 “test” 方法。 “test” 调用 feedForward 方法,并计算网络结果相对于目标值的误差。

        int ep = 0;
        for(; ep < epochs; ep++)
        {
           if(validation.Rows() && check.Rows())
           {
              // if there is validation, run it before normal pass/training
              msev = test(validation, check, lf);
              // smooth errors
              msevma = (msevma ? msevma : msev) * (1 - ema) + ema * msev;
           }
           mse = test(data, target, lf);  // enable feedForward(data) run
           msema = (msema ? msema : mse) * (1 - ema) + ema * mse;
           ...

首先,我们检查误差值是否为有效数字。 否则,网络已溢出,或输入了不正确的数据。

           if(!MathIsValidNumber(mse))
           {
              PrintFormat("NaN at epoch %d", ep);
              break; // will return NaN as error indication
           }

如果新的误差变得比前一个误差还大,并且具有一定的“容差”(由训练数据集和验证数据集的大小比率确定),则循环将中断。

           const int scale = (int)(data.Rows() / (validation.Rows() + 1)) + 1;
           if(msevmap != 0 && ep > p && msevma > msevmap + scale * (msemap - msema))
           {
              // skip the first p epochs to accumulate values for averaging
              PrintFormat("Stop by validation at %d, v: %f > %f, t: %f vs %f", ep, msevma, msevmap, msema, msemap);
              break;
           }
           msevmap = msevma;
           msemap = msema;
           ...

如果误差继续减小或不再增大,则保存新的误差值,以便与下一个世代结果进行比较。

如果误差已达到所需的精度,则认为训练已完成,故此我们退出循环。

           if(mse <= accuracy)
           {
              PrintFormat("Done by accuracy limit %f at epoch %d", accuracy, ep);
              break;
           }

此外,在循环中调用虚拟方法 “progress”,其需在网络的派生类中被覆盖。 它可用于中断训练,以便响应某些用户操作。 “progress” 的标准实现将在稍后展示。

           if(!progress(ep, epochs, mse, msev, msema, msevma))
           {
              PrintFormat("Interrupted by user at epoch %d", ep);
              break;
           }

最后,如果循环没有被上述任何条件中断,我们调用 backProp 开始误差反向传播过程。

           if(!backProp(target))
           {
              mse = NaN(); // error flag
              break;
           }
        }
        
        if(ep == epochs)
        {
           PrintFormat("Done by epoch limit %d with accuracy %f", ep, mse);
        }
        
        return mse;
     }

默认的 “progress” 方法每秒记录一次学习衡量值。

     virtual bool progress(const int epoch, const int total,
        const double error, const double valid = DBL_MAX,
        const double ma = DBL_MAX, const double mav = DBL_MAX)
     {
        static uint trap;
        if(GetTickCount() > trap)
        {
           PrintFormat("Epoch %d of %d, loss %.5f%s%s%s", epoch, total, error,
              ma == DBL_MAX ? "" : StringFormat(" ma(%.5f)", ma),
              valid == DBL_MAX ? "" : StringFormat(", validation %.5f", valid),
              valid == DBL_MAX ? "" : StringFormat(" v.ma(%.5f)", mav));
           trap = GetTickCount() + 1000;
        }
        return !IsStopped();
     }

如果返回 “true”,则训练继续,而 “false” 将导致循环中断。

除了“提早终止”之外,MatrixNet 类还可以随机禁用一些类似于舍弃的连接。

根据传统的舍弃方法,随机选择的神经元暂时被排除在网络之外。 然而,实现这一点的成本很高,因为该算法使用矩阵运算。 为了从层中排除神经元,我们需要在每次迭代时重新格式化权重矩阵,并部分复制它们。 将随机权重设置为 0 会更容易、更高效,这会断开连接。 当然,在每个世代开始时,程序必须将暂时禁用的权重恢复到以前的状态,然后重新随机选择要在下一个世代中禁用的权重。

调用 enableDropOut 方法临时重置连接数,按照网络权重总数的百分比设置。 默认情况下,dropOutRate 变量为 0,因此该模式处于禁用状态。

     void enableDropOut(const uint percent = 10)
     {
        dropOutRate = (int)percent;
     }

舍弃原则是将权重矩阵的当前状态保存在一些额外的存储中(它由 DropOutState 类实现),并重置随机选择的网络连接。 训练网络之后,针对某个世代生成的修改形式,从存储中恢复重置的矩阵元素,并重复该过程:选择并重置其它随机权重,依据它们训练网络,依此类推。 我建议您自行探索 DropOutState 是如何工作的。


自适应学习率

到目前为止,一直假设我们正在使用恒定的学习率(“speed” 变量),但这是不切实际的(学习在低速时可能非常慢,或者在高速下“过度兴奋”)。

学习率调整形式之一就是利用反向传播算法的特殊调整。 它被称为 “rprop”(弹性传播)。 该算法检查每个权重,即上一次迭代和当前迭代中增量的符号是否相同。 如果符号相同,则保留梯度的方向,在这种情况下,可以针对给定的权重选择性地增加速度。 而对于那些梯度符号发生变化的权重,最好放慢速度。

由于矩阵在每个世代一次性计算所有数据,其行为表现为累加整个数据集当中的每个权重的梯度值和符号(并均化)。 因此,该技术更准确地称为 “批量 rprop”。

MatrixNet 类中实现此增强功能的所有代码行都随 BATCH_PROP 宏一起提供。 在将头文件 MatrixNet.mqh 包含在源代码中之前,建议使用以下指令启用自适应速率:

  #define BATCH_PROP

请注意,此模式使用 “speed” 矩阵数组,替代 “speed” 变量。 我们还需要将上一个世代的权重增量存储在 “deltas” 矩阵数组中。

  class MatrixNet
  {
  protected:
     ...
     #ifdef BATCH_PROP
     matrix speed[];
     matrix deltas[];
     #else
     double speed;
     #endif

加速和减速系数,以及最大和最小速度设置在 4 个附加变量当中。

     double plus;
     double minus;
     double max;
     double min;

我们为新数组分配内存,并在已经熟悉的 “allocate” 方法中设置默认变量值。

     void allocate()
     {
        ArrayResize(weights, n);
        ArrayResize(outputs, n + 1);
        ArrayResize(bestWeights, n);
        dropOutRate = 0;
        #ifdef BATCH_PROP
        ArrayResize(speed, n);
        ArrayResize(deltas, n);
        plus = 1.1;
        minus = 0.1;
        max = 50;
        min = 0.0;
        #endif
     }

若要在开始训练之前为这些变量设置其它值,则调用 setupSpeedAdjust 方法。

在 MatrixNet 构造函数中,我们通过复制 “weight” 矩阵数组来初始化 “speed” 和 “deltas” 矩阵 — 这是沿网络层获取相同大小矩阵的更方便的方法。 然后,在接下来的步骤中,会为 “speed” 和 “deltas” 填充有意义的数据。 在 'train' 方法开始时,替代简单地在标量变量 'speed' 中分配精度,而是取该值填充 'speed' 数组中的所有矩阵。

     double train(const matrix &data, const matrix &target,
        const matrix &validation, const matrix &check,
        const int epochs = 1000, const double accuracy = 0.001,
        const ENUM_LOSS_FUNCTION lf = LOSS_MSE)
     {
        ...
        #ifdef BATCH_PROP
        for(int i = 0; i < n; ++i)
        {
           speed[i].Fill(accuracy); // adjust speeds on the fly
           deltas[i].Fill(0);
        }
        #else
        speed = accuracy;
        #endif
        ...
     }

在 backProp 方法中,增量表达式现在引用相应层的矩阵,而非标量。 在收到 “delta” 增量后,我们立即调用 adjustSpeed 方法(如下所示),将 'delta * deltas[i]' 的乘积传递给它,以便比较以前和新的方向。 最后,我们将新的权重增量保存到 “deltas[i]” 中,以便在下一个世代中对其进行分析。

     bool backProp(const matrix &target)
     {
        ...
        for(int i = n - 1; i >= 0; --i) // all layers except the output in reverse order
        {
           ...
           #ifdef BATCH_PROP
           matrix delta = speed[i] * outputs[i].Transpose().MatMul(loss);
           adjustSpeed(speed[i], delta * deltas[i]);
           deltas[i] = delta;
           #else
           matrix delta = speed * outputs[i].Transpose().MatMul(loss);
           #endif
           ...
        }
        ...
     }

adjustSpeed 方法非常简单。 矩阵乘积元素中的正号表示梯度保持不变,速度提升了 “plus” 倍数,但不超过 “max” 值。 负号表示梯度发生变化,速度降低 “minus” 倍,但不能小于 “min” 值。

     void adjustSpeed(matrix &subject, const matrix &product)
     {
        for(int i = 0; i < (int)product.Rows(); ++i)
        {
           for(int j = 0; j < (int)product.Cols(); ++j)
           {
              if(product[i][j] > 0)
              {
                 subject[i][j] *= plus;
                 if(subject[i][j] > max) subject[i][j] = max;
              }
              else if(product[i][j] < 0)
              {
                 subject[i][j] *= minus;
                 if(subject[i][j] < min) subject[i][j] = min;
              }
           }
        }
     }


保存和恢复已训练网络的最佳状态

故此,网络在中循环进行训练,迭代称为“世代”:在每个世代中,训练数据集的所有向量都传递给网络,放置在矩阵中,其中记录按行排列,它们的符号则在列中。 例如,每条记录可以存储报价栏,而列可以存储 OHLC 价格和交易量。

尽管权重调整过程是沿着梯度执行的,但它是随机的,因为由于所求问题的目标函数,和可变速度的不均匀性,我们可以在找到网络误差新的最小值之前周期性地得到“不良”设置。 我们不能保证世代次数的增加,一定会带来训练模型质量的提高和网络误差的减少。

有关于此,持续监控网络的整体误差是有意义的:如果在当前世代之后误差更新出现最小值,则应记录所找到的权重。 出于这些目的,我们将使用另一个权重矩阵数组和带有学习衡量值的 “Stats” 结构。

  class MatrixNet
  {
     ...
  public:
     struct Stats
     {
        double bestLoss; // smallest error for all epochs
        int bestEpoch;   // index of the epoch with the minimum error
        int epochsDone;  // total number of completed epochs
     };
     
     Stats getStats() const
     {
        return stats;
     }
     
  protected:
     matrix bestWeights[];
     Stats stats;
     ...

在训练方法内部,在开始循环遍历世代之前,我们使用统计数据初始化该结构。

     double train(const matrix &data, const matrix &target,
        const matrix &validation, const matrix &check,
        const int epochs = 1000, const double accuracy = 0.001,
        const ENUM_LOSS_FUNCTION lf = LOSS_MSE)
     {
        ...
        stats.bestLoss = DBL_MAX;
        stats.bestEpoch = -1;
        DropOutState state(dropOutRate);

在循环中,如果发现小于最小已知误差值,我们将所有权重矩阵保存在 bestWeights 之中。

        int ep = 0;
        for(; ep < epochs; ep++)
        {
           ...
           const double candidate = (msev != DBL_MAX) ? msev : mse;
           if(candidate < stats.bestLoss)
           {
              stats.bestLoss = candidate;
              stats.bestEpoch = ep;
              // save best weights from 'weights'
              for(int i = 0; i < n; ++i)
              {
                 bestWeights[i].Assign(weights[i]);
              }
           }
        }
        ...

训练后,很容易查询最终的网络权重和最佳权重。

     bool getWeights(matrix &array[]) const
     {
        if(!ready) return false;
        
        ArrayResize(array, n);
        for(int i = 0; i < n; ++i)
        {
           array[i] = weights[i];
        }
        
        return true;
     }
     
     bool getBestWeights(matrix &array[]) const
     {
        if(!ready) return false;
        if(!n || !bestWeights[0].Rows()) return false;
        
        ArrayResize(array, n);
        for(int i = 0; i < n; ++i)
        {
           array[i] = bestWeights[i];
        }
        
        return true;
     }

这些矩阵数组可以保存到一个文件中,以便将来我们可以恢复已训练并准备就绪的网络。 这是在单独的构造函数中完成的。

     MatrixNet(const matrix &w[], const ENUM_ACTIVATION_FUNCTION f1 = AF_TANH,
        const ENUM_ACTIVATION_FUNCTION f2 = AF_NONE):
        ready(false), af(f1), of(f2), n(ArraySize(w))
     {
        if(n < 2) return;
        
        allocate();
        for(int i = 0; i < n; ++i)
        {
           weights[i] = w[i];
           #ifdef BATCH_PROP
           speed[i] = weights[i];  // instead .Init(.Rows(), .Cols())
           deltas[i] = weights[i]; // instead .Init(.Rows(), .Cols())
           #endif
        }
        
        ready = true;
     }

稍后,我们将看到一个实际示例,展示如何保存和读取现成的网络。


网络训练进度可视化

“progress” 方法周期性输出的结果日志不是很清楚。 故此,MatrixNet.mqh 文件还实现了从 MatrixNet 派生的 MatrixNetVisual 类,该类显示了一个按世代变化的训练误差图形。

图形显示由标准 CGraphic 类(在 MetaTrader 5 中可用)提供,或者更确切地说,由它派生的小型 CMyGraphic 类提供。

该类的对象是 MatrixNetVisual 的一部分。 此外,在“可视化”网络内部,我们还有一个由 5 条曲线和双精度型数组组成的数组,用来显示各种线条。

  class MatrixNetVisual: public MatrixNet
  {
     CMyGraphic graphic;
     CCurve *c[5];
     double p[], x[], y[], z[], q[], b[];
     ...

其中:

  • p 是世代数(所有曲线的公共水平 X 轴)
  • x 是训练数据集误差(Y)
  • y 是验证数据集误差(Y)
  • z 是平滑验证误差(Y)
  • q 是平滑学习误差(Y)
  • b 是最小误差(Y)所在的点(世代)

  • 从 MatrixNetVisual 构造函数调用 “graph” 方法创建一个占据整个窗口大小的图形对象。 此处还加入了上述五条曲线(CCurve)。

       void graph()
       {
          ulong width = ChartGetInteger(0, CHART_WIDTH_IN_PIXELS);
          ulong height = ChartGetInteger(0, CHART_HEIGHT_IN_PIXELS);
    
          bool res = false;
          const string objname = "BPNNERROR";
          if(ObjectFind(0, objname) >= 0) res = graphic.Attach(0, objname);
          else res = graphic.Create(0, objname, 0, 0, 0, (int)(width - 0), (int)(height - 0));
          if(!res) return;
    
          c[0] = graphic.CurveAdd(p, x, CURVE_LINES, "Training");
          c[1] = graphic.CurveAdd(p, y, CURVE_LINES, "Validation");
          c[2] = graphic.CurveAdd(p, z, CURVE_LINES, "Val.EMA");
          c[3] = graphic.CurveAdd(p, q, CURVE_LINES, "Train.EMA");
          c[4] = graphic.CurveAdd(p, b, CURVE_POINTS, "Best/Minimum");
          ...
       }
    
    public:
       MatrixNetVisual(const int &layers[], const ENUM_ACTIVATION_FUNCTION f1 = AF_TANH,
          const ENUM_ACTIVATION_FUNCTION f2 = AF_NONE): MatrixNet(layers, f1, f2)
       {
          graph();
       }
    
    

    在重写的 “progress” 方法中,添加的参数对应双精度数组,然后调用 “plot” 方法来更新图像。

         virtual bool progress(const int epoch, const int total,
            const double error, const double valid = DBL_MAX,
            const double ma = DBL_MAX, const double mav = DBL_MAX) override
         {
            // fill all the arrays
            PUSH(p, epoch);
            PUSH(x, error);
            if(valid != DBL_MAX) PUSH(y, valid); else PUSH(y, nan);
            if(ma != DBL_MAX) PUSH(q, ma); else PUSH(q, nan);
            if(mav != DBL_MAX) PUSH(z, mav); else PUSH(z, nan);
            plot();
            
            return MatrixNet::progress(epoch, total, error, valid, ma, mav);
         }
    
    
    

    “plot” 方法完成曲线的绘制。

       void plot()
       {
          c[0].Update(p, x);
          c[1].Update(p, y);
          c[2].Update(p, z);
          c[3].Update(p, q);
          double point[1] = {stats.bestEpoch};
          b[0] = stats.bestLoss;
          c[4].Update(point, b);
          ...
          graphic.CurvePlotAll();
          graphic.Update();
       }
    
    

    您可以自行探索可视化过程的更多技术细节。 我们很快就会看到它在屏幕上的样子。


    测试脚本

    MatrixNet 类家族已准备好进行第一次测试。 它是 MatrixNet.mq5 脚本,其中初始数据是根据已知的分析记录人工生成的。 我们将利用来自机器学习帮助主题中的公式,该公式提供了一个本机反向传播训练示例,该示例不像我们的类那样通用,故此需要大量编码(与以下类比较,其可用和不可用的行数)。

    f = ((x + y + z)^2 / (x^2 + y^2 + z^2)) / 3

    我们公式中仅有的略微区别是该值除以 3,这令函数的范围从 0 到 1。

    函数的形式可参照下图评估,其表面(x<->y)显示了三个不同 z 值:0.05、0.5 和 5.0。

    测试函数的 3个部分

    测试函数的 3个部分

    在脚本输入变量中,我们指定训练世代次数、准确度(终端误差)和噪声强度,我们可以选择将其添加到生成的数据当中(这将令实验更接近实际问题,并将演示噪声如何令识别依赖关系变得困难)。 默认情况下,RandomNoise 为 0,因此没有噪声。

      input int Epochs = 1000;
      input double Accuracy = 0.001;
      input double RandomNoise = 0.0;
    
    

    实验数据由 CreateData 函数生成。 其矩阵参数 “data” 和 “target” 将填充上述函数的点。 点的数量是 “count”。 一个输入向量(“data” 矩阵的行)有 3 列(对应于 x、y、z)。 输出向量(“target” 矩阵的行)是 f 的单个值。 点(x,y,z)在 -10 到 +10 的范围内随机生成。

      bool CreateData(matrix &data, matrix &target, const int count)
      { 
         if(!data.Init(count, 3) || !target.Init(count, 1))
            return false;
         data.Random(-10, 10);
         vector X1 = MathPow(data.Col(0) + data.Col(1) + data.Col(2), 2);
         vector X2 = MathPow(data.Col(0), 2) + MathPow(data.Col(1), 2) + MathPow(data.Col(2), 2);
         if(!target.Col(X1 / X2 / 3.0, 0))
            return false;
         if(RandomNoise > 0)
         {
            matrix noise;
            noise.Init(count, 3);
            noise.Random(0, RandomNoise);
            data += noise - RandomNoise / 2;
            
            noise.Resize(count, 1);
            noise.Random(-RandomNoise / 2, RandomNoise / 2);
            target += noise;
         }
         return true; 
      }
    
    

    RandomNoise 中的噪声强度设置为正确坐标的额外扩散幅度,以及据其获得的函数值。 假设函数最大值为 1.0,这种级别的噪声几乎令其无法识别。

    为了使用神经网络,我们包含了 MatrixNet.mqh 头文件,并在此预处理器指令之前定义 BATCH_PROP 宏,从而实现可变速率的加速学习。

      #define BATCH_PROP
      #include <MatrixNet.mqh>
    
    

    在主脚本函数中,我们使用 “layers” 数组定义网络配置(层数及其大小),并将其传递给 MatrixNetVisual 构造函数。 训练和验证数据集则是通过调用两次 CreateData 生成的。

      void OnStart()
      {
         const int layers[] = {3, 11, 7, 1};
         MatrixNetVisual net(layers);
         matrix data, target;
         CreateData(data, target, 100);
         matrix valid, test;
         CreateData(valid, test, 25);
         ...
    
    

    在实践中,我们应该规范化源数据,删除异常值,在将它们发送到网络之前检查因子的独立性。 但在这种情况下,我们自己生成数据。

    该模型调用 “训练” 方法,依据 “data” 和 “target” 矩阵进行训练。 当有效/测试集的性能下降时,将发生提前终止,但在非噪声数据上,我们可能会达到所需的精度,或最大循环,以更快触发那个为准。

         Print("Training result: ", net.train(data, target, valid, test, Epochs, Accuracy));
         matrix w[];
         if(net.getBestWeights(w))
         {
            MatrixNet net2(w);
            if(net2.isReady())
            {
               Print("Best copy on training data: ", net2.test(data, target));
               Print("Best copy on validation data: ", net2.test(valid, test));
            }
         }
    
    

    训练完毕,我们寻求找到的最佳权重矩阵,并基于它们构建另一个网络实例,即 net2 对象。 之后,依据两个数据集运行网络,并在日志中打印其误差值。

    由于脚本使用具有学习进度可视化的网络,因此我们开始一个循环,等待用户的命令来完成脚本,如此用户就可以研究图形了。

         while(!IsStopped())
         {
            Sleep(1000);
         }
      }
    
    

    当采用默认参数运行脚本时,我们可以得到如下图所示的内容(由于随机数据生成和网络初始化,每次运行都会与有所不同)。

    训练期间的动态网络误差

    训练期间的动态网络误差

    训练集和验证集上的误差分别显示为蓝线和红线,其平滑版本显示为绿色和黄色。 我们可以清楚地看到,随着训练的进行,所有类型的误差都会减少,但是在某个时刻之后,验证误差会大于训练集的误差。 在图表的右边缘附近,它的增加是明显的,导致“提早终止”。 最佳网络配置已被圈出。

    日志可以如下所示:

      EMA for early stopping: 31 (0.062500)
      Epoch 0 of 1000, loss 0.20296 ma(0.20296), validation 0.18167 v.ma(0.18167)
      Epoch 120 of 1000, loss 0.02319 ma(0.02458), validation 0.04566 v.ma(0.04478)
      Stop by validation at 155, v: 0.034642 > 0.034371, t: 0.016614 vs 0.016674
      Training result: 0.015707719706513287
      Best copy on training data: 0.015461956812387292
      Best copy on validation data: 0.03211748853774414
    
    

    如果我们一开始采用 RandomNoise 参数往数据里添加噪声,学习率将明显降低,如果噪声过多,已训练网络的误差将增加,或者彻底停止学习。

    例如,下面是含有噪声 3.0 的图形的样子。

    增加了噪声之后正在训练中的网络误差动态

    增加了噪声之后正在训练中的网络误差动态

    根据日志,误差值要糟糕得多。

      Epoch 0 of 1000, loss 2.40352 ma(2.40352), validation 2.23536 v.ma(2.23536)
      Stop by validation at 163, v: 1.082419 > 1.080340, t: 0.432023 vs 0.432526
      Training result: 0.4244786772678285
      Best copy on training data: 0.4300476339855798
      Best copy on validation data: 1.062895214094978
    
    

    因此,神经网络工具箱运行良好。 现在,我们转到更实际的示例:指标和智能系统。


    预测指标

    作为基于神经网络的预测指标的一个例子,我们来考虑 BPNNMatrixPredictorDemo.mq5,它是参考代码库中现有指标的改编版。 在 MQL5 中实现的神经网络,未用到矩阵,方法是从 C++ 移植同一指标的早期版本(有详细说明,包括神经网络理论的相关部分)。

    指标的操作原理是从过去 EMA 平均价格增量中形成给定长度的输入向量,这些增量位于由斐波那契级数(1,2,3,5,8,13,21,34,55,89,144...)间隔的柱线上。 ..). 基于此信息,指标应预测下一根柱线(位于相应向量中包含的历史柱线右侧)的价格增量。 向量的大小由用户指定的神经网络输入层大小(_numInputs) 决定。 层数(最多 6 层)及其大小在其它输入变量中指定。

      input int _lastBar = 0;     // Last bar in the past data
      input int _futBars = 10;    // # of future bars to predict
      input int _smoothPer = 6;   // Smoothing period
      input int _numLayers = 3;   // # of layers including input, hidden & output (2..6)
      input int _numInputs = 12;  // # of inputs (that is neurons in input 0-th layer)
      input int _numNeurons1 = 5; // # of neurons in the 1-st hidden or output layer
      input int _numNeurons2 = 1; // # of neurons in the 2-nd hidden or output layer
      input int _numNeurons3 = 0; // # of neurons in the 3-rd hidden or output layer
      input int _numNeurons4 = 0; // # of neurons in the 4-th hidden or output layer
      input int _numNeurons5 = 0; // # of neurons in the 5-th hidden or output layer
      input int _ntr = 500;       // # of training sets / bars
      input int _nep = 1000;      // Max # of epochs
      input int _maxMSEpwr = -7;  // Error (as power of 10) for training to stop; mse < 10^this
    
    

    此外,我们在这里指定训练数据集的最大大小(_ntr)、最大周期数(_nep)和最小 MSE 误差(_maxMSEpwr)。

    价格的 EMA 均线周期则在 _smoothPer 里指定。

    默认情况下,指标从最后一根柱线(_lastBar 等于 0)开始获取训练数据,并对未来 _futBars 进行预测(显然,在网络输出处预测 1 根柱线,我们可以逐渐将其“推送”到输入向量中,以便预测随后的几根柱线)。 如果在 _lastBar 中指定了一个正数,我们将获得过往相应柱数的预测,我们能够将其与现有报价进行比较来直观地评估它。

    指标输出 3 个缓冲区:

    • 浅绿线,包含训练数据集的目标值
    • 蓝线,依据训练数据集的网络输出
    • 红线,预测值

    指标生成数据集和可视化结果(初始数据和预测)的应用部分没有变化。

    主要修改是在 Train 和 Test 两个函数中进行的:现在它们将神经网络操作完全委托给 MatrixNet 类的对象。 “Train” 函数基于收集到的数据训练网络,并返回一个含有网络权重的数组(在测试器中运行时,训练只进行一次,在线运行时,打开新柱线就会导致重复训练;这可以在源代码中更改)。 “Test” 函数按权重重新创建网络,并周期性执行一次性预测计算。 将其更优化,即保存经过训练的网络对象,并在无需重新创建的情况下复用。 我们将在下一个示例中使用 EA 执行此操作。 至于指标,我特意采用了旧版本原始代码的结构,以便更方便地比较有矩阵和不带矩阵的编码方法。 特别是,您可以注意这样一个事实,即在矩阵版本中,我们不必一次一个地在网络中运行向量,并根据它们的维度手动重塑数据数组。

    下面是 EURUSD H1图表上采用默认设置的指标。

    基于神经网络的指标进行的预测

    基于神经网络的指标进行的预测

    请注意,此处显示该指标只是为了演示神经网络的性能。 不建议据当前的简化形式制定交易决策。


    把神经网络存储到文件之中

    来自市场的源数据可能会极速变化,一些交易者发现基于最新数据集即时训练网络(每天、每个时段、等等)是值得的。 然而,这样的资源成本可能很昂贵,并且与基于日线数据运行的中长期交易系统无关。 在这种情况下,最好保存经过训练的网络,以便将来可以快速加载和复用。

    为此,在本文的框架内,我们创建了在 MatrixNetStore.mqh 头文件中定义的 MatrixNetStore 类。 该类包括模板方法 “save” 和 “load”,它们期望 MatrixNet 家族中的任何类作为 M 模板参数(现在我们只有两个类,包括 MatrixNetVisual,但如果您愿意,您可以扩展该集合)。 这两种方法都有一个带有文件名的参数,并使用标准神经网络数据进行操作:层数、大小、权重矩阵和激活函数。

    此处是如何保存网络。

      class MatrixNetStore
      {
         static string signature;
      public:
         template<typename M> // M is a MatrixNet
         static bool save(const string filename, const M &net, Storage *storage = NULL, const int flags = 0)
         {
            // get the matrix of weights (the best weights, if any)
            matrix w[];
            if(!net.getBestWeights(w))
            {
               if(!net.getWeights(w))
               {
                  return false;
               }
            }
            // open file
            int h = FileOpen(filename, FILE_WRITE | FILE_BIN | FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_ANSI | flags);
            if(h == INVALID_HANDLE) return false;
            // write network metadata
            FileWriteString(h, signature);
            FileWriteInteger(h, net.getActivationFunction());
            FileWriteInteger(h, net.getActivationFunction(true));
            FileWriteInteger(h, ArraySize(w));
            // write weight matrices
            for(int i = 0; i < ArraySize(w); ++i)
            {
               matrix m = w[i];
               FileWriteInteger(h, (int)m.Rows());
               FileWriteInteger(h, (int)m.Cols());
               double a[];
               m.Swap(a);
               FileWriteArray(h, a);
            }
            // if user data is provided, write it
            if(storage)
            {
              if(!storage.store(h)) Print("External info wasn't saved");
            }
            
            FileClose(h);
            return true;
         }
         ...
      };
         
      static string MatrixNetStore::signature = "BPNNMS/1.0";
    
    

    请注意以下几点。 签名写在文件的开头,以便可用它来检查文件格式的正确性(签名可以更改:类为此提供了方法)。 此外,“save” 方法允许在必要时将任何用户数据添加到有关网络的标准信息中:您只需将指针传递给特殊 Storage 接口的对象。

      class Storage
      {
      public:
         virtual bool store(const int h) = 0;
         virtual bool restore(const int h) = 0;
      };
    
    

    相应地,也可从文件中网络。

      class MatrixNetStore
      {
         ...
         template<typename M> // M is a MatrixNet
         static M *load(const string filename, Storage *storage = NULL, const int flags = 0)
         {
            int h = FileOpen(filename, FILE_READ | FILE_BIN | FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_ANSI | flags);
            if(h == INVALID_HANDLE) return NULL;
            // check the format by signature
            const string header = FileReadString(h, StringLen(signature));
            if(header != signature)
            {
               FileClose(h);
               Print("Incorrect file header");
               return NULL;
            }
            // read standard network metadata set
            const ENUM_ACTIVATION_FUNCTION f1 = (ENUM_ACTIVATION_FUNCTION)FileReadInteger(h);
            const ENUM_ACTIVATION_FUNCTION f2 = (ENUM_ACTIVATION_FUNCTION)FileReadInteger(h);
            const int size = FileReadInteger(h);
            matrix w[];
            ArrayResize(w, size);
            // read weight matrices
            for(int i = 0; i < size; ++i)
            {
               const int rows = FileReadInteger(h);
               const int cols = FileReadInteger(h);
               double a[];
               FileReadArray(h, a, 0, rows * cols);
               w[i].Swap(a);
               w[i].Reshape(rows, cols);
            }
            // read user data
            if(storage)
            {
               if(!storage.restore(h)) Print("External info wasn't read");
            }
            // create a network object
            M *m = new M(w, f1, f2);
            
            FileClose(h);
            return m;
         }
    
    

    现在,我们准备继续本文中的最后一个示例,即交易机器人。


    预测智能系统

    作为 TradeNN.mq5 预测 EA 的策略,我们将采用一个相当简单的原则:在下一根柱线的预测方向上进行交易。 我们的目的是展示神经网络技术的实际应用,而不是在盈利能力的背景下探索所有可预见的适用性因素。

    初始数据将是给定数量柱线上的价格增量。 备选项则是,不仅可以分析当前交易品种,还可以分析其它交易品种,从理论上讲,这将令我们能够识别相互依赖关系(例如,是否一支股票代码间接“跟随”另一支股票代码或其组合)。 网络的输出不应被单一地解释为目标价格。 取而代之,为了简化系统,我们将分析符号:正号 — 买入;负号 — 卖出。

    换言之,网络操作流程在某种意义上是混合的:一方面,网络将解决回归问题;但另一方面,我们将从两个交易操作中选择一个交易操作,就像在分类中一样。 将来,可以将输出层中的神经元数量增加到交易情况的数量,并应用 SoftMax 激活函数。 不过,为了训练这样的网络,有必要根据情况自动或手动标记报价。

    该策略有意制定得非常简单,以便专注于网络参数,而非策略。

    在 “Symbols” 输入参数中指定所要分析的金融产品列表,以逗号作为分隔符。 当前图表的品种也在交易品种当中,且应排在第一位。

      input string Symbols = "XAGUSD,XAUUSD,EURUSD";
      input int Depth = 5; // Vector size (bars)
      input int Reserve = 250; // Training set size (vectors)
    
    

    我选择这些品种作为默认值,因为白银和黄金被认为是相关资产,并且高影响力新闻(与货币相比)相对较少的,因此,我们可以尝试分析白银对比黄金(就像现在一样),和黄金对比白银。 至于 EURUSD,添加该货币作为整个市场的基础。 新闻的存在并不重要,因为它的作用是预测器,而不是预测变化。

    其它最重要的参数之一是形成向量的每个金融产品的柱线数量(深度)。 例如,如果 Symbols 设置了 3 支股票代码,深度设置为 5(默认值),则网络输入向量的总大小为 15。

    Reserve 参数允许设置样本长度(来自最近报价历史形成的向量数量)。 默认值为 250,因为我们的测试将采用日线时间帧,而 250 大约就是 1 年。 相应地,Depth 等于 5 就是一周。

    当然,您可以修改任何设置,包括时间帧,但在更高的时间帧内,例如 D1,推测基本形态比市场对瞬时情况的自发反应更强硬。

    另请注意,在测试器中启动时,它大约预加载了 1 年的报价,如此在 D1+ 以上时间帧训练,需要增加请求的训练数据量,且需跳过一定数量的初始柱线,等待足够数量的柱线累积。

    类似于前面的示例,我们应该在参数中指定训练世代次数和准确度(这也是初始速度,然后通过 “rprop” 为每个突触动态选择速度)。

      input int Epochs = 1000;
      input double Accuracy = 0.0001; // Accuracy (and training speed)
    
    

    在该智能系统中,神经网络将有 5 层:一个输入、三个隐藏层和一个输出层。 输入层的大小决定了输入向量,第二层和第三层则是由 HiddenLayerFactor 选取的。 对于倒数第二层,我们将使用一个经验公式(参见下面的源代码),令其大小介于前一层和输出层(单层)之间。

      input double HiddenLayerFactor = 2.0; // Hidden Layers Factor (to vector size)
      input int DropOutPercentage = 0; // DropOut Percentage
    
    

    我们还将使用此示例来测试舍弃正则化方法:随机重置在 DropOutPercent 参数中指定的权重百分比。 此处未提供验证采样,但如果您愿意,可以组合这两种方法,因为该类允许这样做。

    NetBinFileName 参数用于从文件加载网络。 文件总是相对于共用终端文件夹进行搜索,因为否则的话,若要在策略测试器中测试智能系统,我们需要在源代码中提前指定所有必要的网络名称,在 #property tester_file 指令中 — 这是将它们发送给代理的唯一方式。

    当 NetBinFileName 参数为空时,EA 会训练一个新网络,并将其保存在含有唯一临时名称的文件之中。 即使在优化过程中也可以执行此操作,这允许生成大量网络配置(针对不同的向量大小、层、舍弃和历史深度)。

      input string NetBinFileName = "";
      input int Randomizer = 0;
    
    

    此外,Randomizer 参数能够以不同的方式初始化随机生成器,因此我们可以按相同的其它设置训练许多网络实例。 请注意,由于随机化,每个网络都是唯一的。 潜在地,使用神经网络联席会,从中采纳合并决策,或多数规则是另一种正则化。

    通过将随机发生器设置为特定值,我们可以复制相同的训练过程,以便进行调试

    按交易品种的价格信息以 Closes 结构、以及 CC 结构数组加以存储:结果就是,我们得到的东西类似于数组的数组。

      struct Closes
      {
         double C[];
      };
         
      Closes CC[];
    
    

    全局数组 S 和变量 Q 保留用于正在操作的金融产品及其编号。 它们在 OnInit 中被填充。

      string S[];
      int Q;
         
      int OnInit()
      {
         Q = StringSplit(StringLen(Symbols) ? Symbols : _Symbol, ',', S);
         ArrayResize(CC, Q);
         MathSrand(Randomizer);
         ...
         return INIT_SUCCEEDED;
      }
    
    

    Calc 函数用于从 “offset” 处请求指定 Depth 的柱线报价。 CC 数组在此函数中填充。 稍后我们将看到如何调用此函数。

      bool Calc(const int offset)
      {
         const datetime dt = iTime(_Symbol, _Period, offset);
         for(int i = 0; i < Q; ++i)
         {
            const int bar = iBarShift(S[i], PERIOD_CURRENT, dt);
            // +1 for differences, +1 for model
            const int n = CopyClose(S[i], PERIOD_CURRENT, bar, Depth + 2, CC[i].C);
            
            for(int j = 0; j < n - 1; ++j)
            {
               CC[i].C[j] = (CC[i].C[j + 1] - CC[i].C[j]) /
                  SymbolInfoDouble(S[i], SYMBOL_TRADE_TICK_SIZE) * SymbolInfoDouble(S[i], SYMBOL_TRADE_TICK_VALUE);
            }
            
            ArrayResize(CC[i].C, n - 1);
         }
         
         return true;
      }
    
    

    然后,对于特定的 CC[i].C 数组,特殊的 Diff 函数将能够计算价格增量,这些增量将被发送到网络的输入向量之中。 除最后一个增量之外,该函数将所有增量写入通过引用传递的 d 数组,并直接返回最后一个增量,该增量将成为目标预测值。

      double Diff(const double &a[], double &d[])
      {
         const int n = ArraySize(a);
         ArrayResize(d, n - 1); // -1 minus the "future" model
         double overall = 0;
         for(int j = 0; j < n - 1; ++j) // left (from old) to right (toward new)
         {
            int k = n - 2 - j;
            overall += a[k];
            d[j] = overall / sqrt(j + 1);
         }
         ... // additional normalization
         return a[n - 1];
      }
    
    

    请注意,根据时间序列“随机游走”理论,我们将差值归一化为柱线距离的平方根(如果我们将过去视为已起作用的预测,则与置信区间成正比)。 这不是必要的技术,但使用神经网络通常类似于研究。

    选择因子(不仅是价格,还有指标、交易量等)和为网络准备数据(规范化、编码)的整个过程是一个单独的广泛主题。 尽可能促进神经网络的计算工作很重要,否则它可能无法应对任务。

    在 EA 的主函数 OnTick 当中,所有操作仅在柱线开盘后才会执行。 由于 EA 分析不同金融产品的报价,因此有必要在继续操作之前同步它们的柱线。 同步由 Sync 函数执行,于此未展示其代码。 有趣的是,基于 Sleep 函数应用的同步甚至适用于在开盘价模式下进行测试。 出于效率原因,我们稍后将使用此模式。

      void OnTick()
      {
         ...
         static datetime last = 0;
         if(last == iTime(_Symbol, _Period, 0)) return;
         ...
    
    

    网络实例存储在自动指针类型的 “run” 变量中(AutoPtr.mqh 头文件)。 因此,我们不需要控制内存的释放。 “std” 变量用于存储从上面讨论的 Calc 和 Diff 函数获得的数据集上计算的方差。 规范化数据时需要该方差。

         static AutoPtr<MatrixNet> run;
         static double std;
    
    

    如果用户在 NetBinFileName 中指定了要加载的文件名,程序将尝试使用 LoadNet 加载网络(见下文)。 此函数若成功,则返回指向网络对象的指针。

         if(NetBinFileName != "")
         {
            if(!run[])
            {
               run = LoadNet(NetBinFileName, std);
               if(!run[])
               {
                  ExpertRemove();
                  return;
               }
            }
         }
    
    

    如果有网络,我们就执行预测和交易:TradeTest 负责所有这些(见 下文)。

         if(run[])
         {
            TradeTest(run[], std);
         }
         else
         {
            run = TrainNet(std);      
         }
         
         last = iTime(_Symbol, _Period, 0);
      }
    
    

    如果还没有网络,我们生成一个训练数据集,并调用 TrainNet 来训练网络。 此函数还返回指向新网络对象的指针,此外,它还使用计算的数据方差填充通过引用传递的 “std” 变量。

    请注意,只有当所有工作品种的历史记录至少包含请求的柱线数量时,网络才能进行训练。 对于在线图表,这很可能在您启动智能系统时立即发生(除非用户输入了过高的数字)。 在测试器中,预加载的历史记录通常限制为一年,因此可能需要将开始时间平移到更久远。 在这种情况下,您将拥有训练网络所需的柱线数量。

    在 OnTick 函数的开头添加了检查柱线是否足够,但本文中没有提供(请参阅完整的源代码)。

    网络训练之后,EA 将开始交易。 对于测试器来说,这意味着我们是在针对已训练网络进行前向验证测试。 获得的财务读数可用于优化,以便选择最合适的网络配置,或网络委员会(配置相同)。

    下面是 TrainNet 函数(注意 Calc 和 Diff 调用)。

      MatrixNet *TrainNet(double &std)
      {
         double coefs[];
         matrix sys(Reserve, Q * Depth);
         vector model(Reserve);
         vector t;
         datetime start = 0;
        
         for(int j = Reserve - 1; j >= 0; --j) // loop through historical bars
         {
            // since close prices are used, we make +1 to the bar index
            if(!Calc(j + 1)) // collect data for all symbols starting with bar j to Depth bars
            {
               return NULL; // probably other symbols don't have enough history (wait)
            }
            // remember training sample start date/time
            if(start == 0) start = iTime(_Symbol, _Period, j);
          
            ArrayResize(coefs, 0);
          
            // calculate price difference for all symbols for Depth bars
            for(int i = 0; i < Q; ++i)
            {
               double temp[];
               double m = Diff(CC[i].C, temp);
               if(i == 0)
               {
                  model[j] = m;
               }
               int dest = ArraySize(coefs);
               ArrayCopy(coefs, temp, dest, 0);
            }
          
            t.Assign(coefs);
            sys.Row(t, j);
         }
         
         // normalize
         std = sys.Std() * 3;
         Print("Normalization by 3 std: ", std);
         sys /= std;
         matrix target = {};
         target.Col(model, 0);
         target /= std;
        
         // the size of layers 0, 1, 2, 3 is derived from the data, always one output
         int layers[] = {0, 0, 0, 0, 1};
         layers[0] = (int)sys.Cols();
         layers[1] = (int)(sys.Cols() * HiddenLayerFactor);
         layers[2] = (int)(sys.Cols() * HiddenLayerFactor);
         layers[3] = (int)fmax(sqrt(sys.Rows()), fmax(sqrt(layers[1] * layers[3]), sys.Cols() * sqrt(HiddenLayerFactor)));
         
         // create and configure the network of the specified configuration
         ArrayPrint(layers);
         MatrixNetVisual *net = new MatrixNetVisual(layers);
         net.setupSpeedAdjustment(SpeedUp, SpeedDown, SpeedHigh, SpeedLow);
         net.enableDropOut(DropOutPercentage);
    
         // train the network and display the result (error)
         Print("Training result: ", net.train(sys, target, Epochs, Accuracy));
         ...
    
    

    我们所用的是具有可视化功能的网络类,因此学习进度将显示在图表上。 训练后,如果不再需要图片对象,您可以手动删除它。 当您卸载 EA 时,图片也将自动删除。

    接下来,我们需要从网络中读取最佳权重矩阵。 此外,我们检查使用这些权重成功重建网络的能力,并使用相同的数据测试其性能。

         matrix w[];
         if(net.getBestWeights(w))
         {
            MatrixNet net2(w);
            if(net2.isReady())
            {
               Print("Best result: ", net2.test(sys, target));
               ...
            }
         }
         return net;
      }
    
    

    最后,将网络与描述训练条件的专门准备的文本一起保存到文件之中:历史间隔、交易品种列表和时间帧、数据大小、网络设置。

            // the most important or all EA settings can be added to the network file
            const string context = StringFormat("\r\n%s %s %s-%s", _Symbol, EnumToString(_Period),
               TimeToString(start), TimeToString(iTime(_Symbol, _Period, 0))) + "\r\n" +
               Symbols + "\r\n" + (string)Depth + "/" + (string)Reserve + "\r\n" +
               (string)Epochs + "/" + (string)Accuracy + "\r\n" +
               (string)HiddenLayerFactor + "/" + (string)DropOutPercentage + "\r\n";
               
            // prepare a temporary file name
            const string tempfile = "bpnnmtmp" + (string)GetTickCount64() + ".bpn";
            
            // save the network and user data to a file
            MatrixNetStore store;                                   // main class unloading/loading the networks
            BinFileNetStorage writer(context, net.getStats(), std); // optional class with our information
            store.save(tempfile, *net, &writer);
            ...
    
    

    这里提到的 BinFileNetStorage 类特定于我们的 EA。 它使用被覆盖的 store/restore 方法(Storage 父接口)来处理我们的附加描述、规范化值(新数据的常规工作将需要它),以及 MatrixNet::Stats 结构形式的训练统计信息。

    进而,EA 行为取决于它是否在优化模式下运行。 在优化过程中,我们将使用帧机制将网络文件从代理者发送到终端(参见源代码)。 这样的类文件存储在本地 MQL5/Files/ 文件夹之中,位于与 EA 同名的子文件夹中。

            if(!MQLInfoInteger(MQL_OPTIMIZATION))
            {
               // set a new name in a more understandable time format, in the common folder
               string filename = "bpnnm" + TimeStamp((datetime)FileGetInteger(tempfile, FILE_MODIFY_DATE))
                  + StringFormat("(%7g)", net.getStats().bestLoss) + ".bpn";
               if(!FileMove(tempfile, 0, filename, FILE_COMMON))
               {
                  PrintFormat("Can't rename temp-file: %s [%d]", tempfile, _LastError);
               }
            }
            else
            {
               ... // the file will be sent from the agent to the terminal as a frame
            }
    
    

    在其它情况下(简单测试或联机工作),文件将移到共用终端文件夹。 这样做是为了简单地将来通过 NetBinFileName 参数加载。 事实上,为了在测试器中工作,我们需要在 #property tester_file 指令里指定 NetBinFileName 参数中输入的特定文件名,然后我们需要重新编译 EA。 若没有这些额外的操作,则网络文件不会被复制到代理者。 因此,对于所有本地代理者,访问公共文件夹更为实用。

    LoadNet 函数的实现方式如下:

      MatrixNet *LoadNet(const string filename, double &std, const int flags = FILE_COMMON)
      {
         BinFileNetStorage reader; // optional user data
         MatrixNetStore store;     // general metadata
         MatrixNet *net;
         std = 1.0;
         Print("Loading ", filename);
         ResetLastError();
         net = store.load<MatrixNet>(filename, &reader, flags);
         if(net == NULL)
         {
            Print("Failed: ", _LastError);
            return NULL;
         }
         MatrixNet::Stats s[1];
         s[0] = reader.getStats();
         ArrayPrint(s);
         std = reader.getScale();
         Print(std);
         Print(reader.getDescription());
         return net;
      }
    
    

    TradeTest 函数调用 Calc(0) 来获取实际价格增量的向量。

      bool TradeTest(MatrixNet *net, const double std)
      {
         if(!Calc(0)) return false;
         double coefs[];
         for(int i = 0; i < Q; ++i)
         {
            double temp[];
            // difference on the 0th bar is ignored, it will be predicted
            /* double m = */Diff(CC[i].C, temp, true);
            ArrayCopy(coefs, temp, ArraySize(coefs), 0);
         }
          
         vector t;
         t.Assign(coefs);
          
         matrix data = {};
         data.Row(t, 0);
         data /= std;
         ...
    
    

    基于该向量,网络必须进行预测。 但在此之前,现有的持仓会被强行平仓:我们不会分析新旧方向是否冲突。 用于平仓的 ClosePosition 方法如下所示。 然后,根据前馈结果,我们在预期方向上开立新仓位。

         ClosePosition();
         
         if(net.feedForward(data))
         {
            matrix y = net.getResults();
            Print("Prediction: ", y[0][0] * std);
            
            OpenPosition((y[0][0] > 0) ? ORDER_TYPE_BUY : ORDER_TYPE_SELL);
            return true;
         }
         return false;
      }
    
    

    OpenPosition 和 ClosePosition 函数类似。 如此,我只在这里展示 ClosePosition。

      bool ClosePosition()
      {
         // define an empty structure
         MqlTradeRequest request = {};
         
         if(!PositionSelect(_Symbol)) return false;
         const string pl = StringFormat("%+.2f", PositionGetDouble(POSITION_PROFIT));
         
         // fill in the required fields
         request.action = TRADE_ACTION_DEAL;
         request.position = PositionGetInteger(POSITION_TICKET);
         const ENUM_ORDER_TYPE type = (ENUM_ORDER_TYPE)(PositionGetInteger(POSITION_TYPE) ^ 1);
         request.type = type;
         request.price = SymbolInfoDouble(_Symbol, type == ORDER_TYPE_BUY ? SYMBOL_ASK : SYMBOL_BID);
         request.volume = PositionGetDouble(POSITION_VOLUME);
         request.deviation = 5;
         request.comment = pl;
         
         // send request
         ResetLastError();
         MqlTradeResult result[1];
         const bool ok = OrderSend(request, result[0]);
         
         Print("Status: ", _LastError, ", P/L: ", pl);
         ArrayPrint(result);
         
         if(ok && (result[0].retcode == TRADE_RETCODE_DONE
                || result[0].retcode == TRADE_RETCODE_PLACED))
         {
            return true;
         }
         
         return false;
      }
    
    

    到了进行实际研究的时候了。 我们在测试器中以默认设置,在 XAGUSD,D1 图表上,以开盘价模式运行 EA。 我们将测试开始日期设置为 2022.01.01。 这意味着在 EA 启动后,网络将立即开始使用上一年 2021 年的价格进行学习,然后它将根据其信号进行交易。 为了按世代查看误差变化图,请在可视模式下运行测试器。

    日志将包含与神经网络训练相关的记录。

      Sufficient bars at: 2022.01.04 00:00:00
      Normalization by 3 std: 1.3415995381755823
      15 30 30 21  1
      EMA for early stopping: 31 (0.062500)
      Epoch 0 of 1000, loss 2.04525 ma(2.04525)
      Epoch 121 of 1000, loss 0.31818 ma(0.36230)
      Epoch 243 of 1000, loss 0.16857 ma(0.18029)
      Epoch 367 of 1000, loss 0.09157 ma(0.09709)
      Epoch 479 of 1000, loss 0.06454 ma(0.06888)
      Epoch 590 of 1000, loss 0.04875 ma(0.05092)
      Epoch 706 of 1000, loss 0.03659 ma(0.03806)
      Epoch 821 of 1000, loss 0.03043 ma(0.03138)
      Epoch 935 of 1000, loss 0.02721 ma(0.02697)
      Done by epoch limit 1000 with accuracy 0.024416
      Training result: 0.024416206367547762
      Best result: 0.024416206367547762
      Check-up of saved and restored copy: bpnnm202302121707(0.0244162).bpn
      Loading bpnnm202302121707(0.0244162).bpn
          [bestLoss] [bestEpoch] [trainingSet] [validationSet] [epochsDone]
      [0]      0.024         999           250               0         1000
      1.3415995381755823
         
      XAGUSD PERIOD_D1 2021.01.18 00:00-2022.01.04 00:00
      XAGUSD,XAUUSD,EURUSD
      5/250
      1000/0.0001
      2.0/0
         
      Best result restored: 0.024416206367547762
    
    

    注意最终的误差值。 稍后,我们将在启用舍弃模式的情况,按不同强度重复测试,并比较结果。

    此处是交易报告。

    预测交易报告示例

    预测交易报告示例

    显而易见,在 2022 年的大部分时间里,交易进展并不令人满意。 然而,在左侧,紧接 2021 年之后,即训练数据集,有一个短暂的盈利期。 大概,网络发现的形态继续操作了一段时间。 如果我们想找出是否真的如此,以及是否应该以任何方式更改网络或训练集的设置,以便提高性能时,我们就需要对每个特定的交易系统进行全面的研究。 这需要大量的艰苦工作,而与神经网络算法的内部实现无关。 在此,我们只做一个最低程度的分析。

    日志显示了已训练网络的文件名。 在测试器的 NetBinFileName 参数中指定它,并延长测试时间,从 2021 年开始。 在此模式下,除前两个参数(Symbols 和 Depth)外,所有输入参数都没有意义。

    延长间隔的测试交易显示以下余额动态(训练数据集以黄色高亮显示)。

    延长间隔交易时的余额曲线,包括训练集

    延长间隔交易时的余额曲线,包括训练集

    正如预期的那样,网络学习到特定间隔的细节,但在完成后不久,它就不再盈利。

    我们重复网络训练两次:舍弃为 25% 和 50%(DropOutPercent 参数应依次设置为 25,然后设置为 50)。 为了启动新网络的训练,需清除 NetBinFileName 参数,并将测试开始日期返回到 2022.01.01。

    舍弃度为 25% 时,我们得到的误差明显大于第一种情况。 这是一个预期的结果,因为我们正尝试通过粗粒化模型来将其适用性扩展到样本外的数据。

      Epoch 0 of 1000, loss 2.04525 ma(2.04525)
      Epoch 125 of 1000, loss 0.46777 ma(0.48644)
      Epoch 251 of 1000, loss 0.36113 ma(0.36982)
      Epoch 381 of 1000, loss 0.30045 ma(0.30557)
      Epoch 503 of 1000, loss 0.27245 ma(0.27566)
      Epoch 624 of 1000, loss 0.24399 ma(0.24698)
      Epoch 744 of 1000, loss 0.22291 ma(0.22590)
      Epoch 840 of 1000, loss 0.19507 ma(0.20062)
      Epoch 930 of 1000, loss 0.18931 ma(0.19018)
      Done by epoch limit 1000 with accuracy 0.182581
      Training result: 0.18258059873803228
    
    

    舍弃度为 50% 时,误差增加得更多。

      Epoch 0 of 1000, loss 2.04525 ma(2.04525)
      Epoch 118 of 1000, loss 0.54929 ma(0.55782)
      Epoch 242 of 1000, loss 0.43541 ma(0.45008)
      Epoch 367 of 1000, loss 0.38081 ma(0.38477)
      Epoch 491 of 1000, loss 0.34920 ma(0.35316)
      Epoch 611 of 1000, loss 0.30940 ma(0.31467)
      Epoch 729 of 1000, loss 0.29559 ma(0.29751)
      Epoch 842 of 1000, loss 0.27465 ma(0.27760)
      Epoch 956 of 1000, loss 0.25901 ma(0.26199)
      Done by epoch limit 1000 with accuracy 0.251914
      Training result: 0.25191436104184456
    
    

    下图显示了三种变体的训练图。

    拥有不同舍弃值的学习动态

    拥有不同舍弃值的学习动态

    以下是余额曲线(训练数据集以黄色高亮显示)。

    根据不同舍弃度的网络所做的预测交易余额曲线

    根据不同舍弃度的网络所做的预测交易余额曲线

    由于舍弃时权重随机断开,训练期的余额曲线变得不如全网流畅,总利润自然减少。

    在这个实验中,所有选项都很快(在一两个月内)与市场丧失感触,但实验的本质是测试创建的神经网络工具,而不是开发一个完整的系统。

    一般来说,25% 的平均舍弃值似乎更理想,因为较小程度的正则化会导致我们回到过度拟合,而较大的正则化程度会破坏网络的计算能力。 然而,我们可以初步得出的主要结论是,神经网络方法并非可以“拯救”任何交易系统的灵丹妙药。 失效可能是由于对特定依赖项存在的不正确假设,或不同算法模块的错误参数,或准备的数据有误引起的。

    在放弃不采用该(或任何其它)交易系统的决定之前,您应该尝试各种方法来找到最佳网络设置,就像通常为没有 AI 的 EA 所做的那样。 我们需要收集更多的统计数据,以便得出更具说服力的结论。

    特别是,我们可以搜索其它品种或时间帧集群,对当前可用的共用变量运行优化,或扩展其列表(例如,通过添加激活函数、向量生成方法、按星期几过滤、等等)。

    神经网络的运用绝不会减轻交易者产生假想、测试想法和重要因素的需要。 唯一的区别是交易系统设置的优化由神经网络的元参数补充。

    作为实验,我们对向量大小、向量数量、隐藏层大小因子和舍弃进行优化。 此外,我们将在优化中包含 Randomizer 参数。 这将允许为其它设置的每个组合生成多个网络实例。

    • Vector size (Depth) — 从 1 到 5
    • Training set (Reserve) — 从 50 到 400 增量为 50
    • Hidden Layer Factor — 从 1 到 5
    • DropOut — 0, 25%, 50%
    • Randomizer — 从 0 到 9

    文后附带设置 .set 文件。 日期间隔为 2022.01.01 到 2023.02.15。

    对于优化准则,我们会用到的,例如利润因子。 虽然,给定较小的组合数量(6000),并完成它们的迭代(与遗传优化不同),这并不重要。

    为了分析优化结果,我们可以将数据导出到 XML 文件,或直接使用 .opt 文件,如来自文章测试器定量和可视化分析报告 所述的 OLAP 程序,或使用任何其它脚本(opt 是一种开放格式)。

    优化报表的统计分析

    优化报表的统计分析

    对于此屏幕截图,变量在请求的细分中聚合(保留 X(水平轴)相对于 Y(颜色标记),DropOutPercent 25% 作为 Z),使用特定的利润因子计算(按 X/Y/Z 轴中的单元格),从恢复因子(从优化期间测试器的每次验算)。 这种人为的品质衡量标准并不理想,但其可开箱即用。

    可以在 Excel 中计算类似或更熟悉的统计信息。

    隐藏层因子为 1(而不是默认为 2),且矢量大小为 4(而不是 5)时,从统计上获得了更好的性能。 建议的舍弃值为 25% 或 50%,但不是 0%。

    此外,正如预期的那样,更深的历史记录是可取的(计数达 350 或 400 个,且可进一步增加是合理的)。

    我们总结一下找到的工作设置:

    • Vector size = 4
    • Training set = 400
    • Hidden Layer Factor = 1

    由于在优化中使用了 Randomizer 参数,因此我们在此配置中训练了 30 个网络实例:每个舍弃级别 (0%、25%、50%)有 10 个网络。 我们需要 25% 和 50%。 通过上传 XML 格式的优化报告,我们可以过滤必要的记录,并得到一个表格(按盈利能力排序,过滤器大于 1):

    Pass    Result  Profit  Expected Profit  Recovery Sharpe Custom  Equity Trades Depth  Reserve Hidden  DropOut Randomizer
    			Payoff	 Factor	 Factor	 Ratio	 	 DD %			      LayerF	Perc
    3838    1.35    336.02  2.41741  1.34991 1.98582 1.20187 1       1.61    139     4       400     1       25      6
    838     1.23    234.40  1.68633  1.23117 0.81474 0.86474 1       2.77    139     4       400     1       25      1
    3438    1.20    209.34  1.50604  1.20481 0.81329 0.78140 1       2.47    139     4       400     1       50      5
    5838    1.17    173.88  1.25094  1.16758 0.61594 0.62326 1       2.76    139     4       400     1       50      9
    5038    1.16    167.98  1.20849  1.16070 0.51542 0.60483 1       3.18    139     4       400     1       25      8
    3238    1.13    141.35  1.01691  1.13314 0.46758 0.48160 1       2.95    139     4       400     1       25      5
    2038    1.11    118.49  0.85245  1.11088 0.38826 0.41380 1       2.96    139     4       400     1       25      3
    4038    1.10    107.46  0.77309  1.09951 0.49377 0.38716 1       2.12    139     4       400     1       50      6
    1438    1.10    104.52  0.75194  1.09700 0.51681 0.37404 1       1.99    139     4       400     1       25      2
    238     1.07    73.33   0.52755  1.06721 0.19040 0.26499 1       3.69    139     4       400     1       25      0
    2838    1.03    34.62   0.24907  1.03111 0.10290 0.13053 1       3.29    139     4       400     1       50      4
    2238    1.02    21.62   0.15554  1.01927 0.05130 0.07578 1       4.12    139     4       400     1       50      3
    
    

    我们取最好的一个,第一行。

    在优化过程中,所有经过训练的网络都保存在 MQL5/Files/<expert name>/<优化日期> 文件夹中。 实际上,这可以省略,因为可以按照 Randomizer 值重新训练类似的网络,但前提是输入数据完全匹配。 如果报价历史发生变化(例如,您使用另一个代理者),则无法完全复制含有这些特征的网络。

    在指定文件夹中的文件,其名称由优化参数的名称和值组成。 因此,您可以简单地搜索文件系统:

    Depth=4-Reserve=400-HiddenLayerFactor=1-DropOutPercentage=25-Randomizer=6

    假设文件被命名为:

    Depth=4-Reserve=400-HiddenLayerFactor=1-DropOutPercentage=25-Randomizer=6-3838(0.428079).bpn

    其中括号中的数字是网络误差,括号前面的数字是验算号码。

    我们看看文件内部:尽管文件是二进制的,但我们的训练元数据在其末尾保存为文本。 因此,我们看到训练间隔为 2021.01.12 00:00-2022.07.28 00:00 (400 bars D1 )。

    我们复制文件名较短的文件(例如 test3838.bpn)到终端共用文件夹。

    在 NetBinFileName 参数中指定名称 test3838.bpn,并将 “Vector size”(深度)设置为 4(如果我们仅在预测模式下工作,则所有其它参数无关紧要)。

    我们在更长的时间区间内验证 EA 交易:由于 2022-2023 年被用作验证前测验证测试,我们将 2020 年捕获为未知时期。

    训练集之外的预测交易测试失败的示例

    训练集之外的预测交易测试失败的示例

    奇迹没有发生:系统在新数据上也无利可图。 对于其它设置,图片也与其类似。

    所以,我们有两个消息:好的和坏的。

    坏消息是,提议的思路不起作用 — 它要么根本不起作用,要么是由于我们演示中检查的因子空间的限制(因为我们没有对数十亿种组合和数百个品种运行超大型优化)。

    好消息是,所提出的神经网络工具包可用于评估思路,并产生预期的(从技术角度来看)结果。


    结束语

    本文介绍了利用 MQL5 矩阵的反向传播神经网络类。 该实现不依赖于外部程序,例如 Python,并且不需要特殊的固件(如支持 OpenCL 的图形加速器)。 除了常规的神经网络训练和操作模式外,这些类还提供了过程可视化,以及将网络保存在文件中的功能。

    依靠这些类,可以很容易地把神经网络集成到任何程序当中。 然而,请注意,该网络只是应用于某些素材(在我们的例子中:财务数据)的工具。 如果素材未包含足够的信息,非常嘈杂或无关紧要,则没有神经网络能够从中找到圣杯。

    反向传播算法是最常见的基本学习方法之一,可以作为构建更复杂的神经网络技术的基础,如循环网络、卷积网络和强化学习。

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

    附加的文件 |
    MQL5bpnm.zip (22.73 KB)
    数据科学与机器学习(第 11 部分):朴素贝叶斯(Bayes),交易中的概率论 数据科学与机器学习(第 11 部分):朴素贝叶斯(Bayes),交易中的概率论
    概率交易就像走钢丝一样 — 它需要精确、平衡和对风险的敏锐理解。 在交易世界中,概率就是一切。 这是成功与失败、盈利与亏损的区别。 通过利用概率的力量,交易者可以做出明智的决策,有效地管理风险,并实现他们的财务目标。 故此,无论您是经验丰富的投资者还是交易新手,了解概率都是解锁您的交易潜能的关键。 在本文中,我们将探索令人兴奋的概率交易世界,并向您展示如何将您的交易博弈提升到一个新的水平。
    种群优化算法:和弦搜索(HS) 种群优化算法:和弦搜索(HS)
    在本文中,我将研究和测试最强大的优化算法 — 和弦搜索(HS),其灵感来自寻找完美声音和声的过程。 那么现在什么算法在我们的评级中处于领先地位?
    MQL5 中的范畴论 (第 2 部分) MQL5 中的范畴论 (第 2 部分)
    范畴论是数学的一个多样化和不断扩展的分支,到目前为止,在 MQL5 社区中还相对难以发现。 这些系列文章旨在介绍和研究其一些概念,其总体目标是建立一个开放的函数库,提供洞察力,同时希望在交易者的策略开发中进一步运用这一非凡的领域。
    构建自动运行的 EA(第 15 部分):自动化(VII) 构建自动运行的 EA(第 15 部分):自动化(VII)
    我们将继续讨论上一篇文章的主题,以便完成有关自动化的这一系列文章。 我们将看到所有内容如何搭配到一起,令 EA 像钟表一样运行。